diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx index 5afe536a..f0832d70 100644 --- a/apps/web/app/(auth)/layout.tsx +++ b/apps/web/app/(auth)/layout.tsx @@ -1,6 +1,4 @@ import { FEATURES } from '@/hooks/use-feature-flag' -import { AuthRouteGuard } from '@/lib/session/auth-route-guard' -import { SessionProvider } from '@/lib/session/session-provider' import { ROUTES } from '@/shared/config/routes' import Link from 'next/link' import { notFound } from 'next/navigation' @@ -15,43 +13,39 @@ export default function AuthLayout({ children }: Props) { } return ( - - -
-
-
-
-
+
+
+
+
+
-
-
- -
- TT -
-
-

- TRACKER TASK -

-

Вернуться на главную

-
- - - - На главную - +
+
+ +
+ TT +
+
+

+ TRACKER TASK +

+

Вернуться на главную

+ -
{children}
-
+ + На главную +
- - + +
{children}
+
+
) } diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 46dbdd6b..1b8930d9 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -2,9 +2,9 @@ import { useLogin } from '@/hooks/api/use-auth' import { isApiError } from '@/lib/api/utils' -import { useSessionStore } from '@/lib/session' import { ROUTES } from '@/shared/config/routes' import Link from 'next/link' +import { useRouter } from 'next/navigation' import { Button, Form, toast } from '@repo/ui' @@ -16,12 +16,12 @@ export default function LoginPage() { const form = useLoginForm() const loginMutation = useLogin() - const session = useSessionStore() + const router = useRouter() const onSubmit = async (data: LoginFormValues) => { try { - const { accessToken } = await loginMutation.mutateAsync(data) - session.setAuthenticated(accessToken) + await loginMutation.mutateAsync(data) + router.push(ROUTES.teams) } catch (error) { if (isApiError(error)) { toast.error(error.message) diff --git a/apps/web/app/(auth)/register/page.tsx b/apps/web/app/(auth)/register/page.tsx index 2c5f8dec..c883890a 100644 --- a/apps/web/app/(auth)/register/page.tsx +++ b/apps/web/app/(auth)/register/page.tsx @@ -2,9 +2,9 @@ import { useRegister } from '@/hooks/api/use-auth' import { isApiError } from '@/lib/api/utils' -import { useSessionStore } from '@/lib/session' import { ROUTES } from '@/shared/config/routes' import Link from 'next/link' +import { useRouter } from 'next/navigation' import { Button, Form, toast } from '@repo/ui' @@ -16,12 +16,12 @@ export default function RegisterPage() { const form = useRegisterForm() const registerMutation = useRegister() - const session = useSessionStore() + const router = useRouter() const onSubmit = async (data: RegisterFormValues) => { try { - const { accessToken } = await registerMutation.mutateAsync(data) - session.setAuthenticated(accessToken) + await registerMutation.mutateAsync(data) + router.push(ROUTES.teams) } catch (error) { if (isApiError(error)) { toast.error(error.message) diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx index 46611ea6..1f689d7b 100644 --- a/apps/web/app/(dashboard)/layout.tsx +++ b/apps/web/app/(dashboard)/layout.tsx @@ -1,5 +1,3 @@ -import { ProtectedRouteGuard } from '@/lib/session/protected-route-guard' -import { SessionProvider } from '@/lib/session/session-provider' import { MainLayout } from '@/widgets/main-layout' interface Props { @@ -9,11 +7,9 @@ interface Props { export default function DashboardLayout({ children, modal }: Props) { return ( - - - {children} - {modal} - - + <> + {children} + {modal} + ) } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 3f55f5f7..23471b87 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -19,7 +19,7 @@ export default function RootLayout({ }>) { return ( - + {children} diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index 93070772..7e2dc72d 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -4,14 +4,14 @@ import { ThemeSync } from '@/features/theme' import { QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' -import { queryClient } from '../lib/query-client' +import { getQueryClient } from '../lib/query-client' interface Props { children: React.ReactNode } const Providers = ({ children }: Props) => ( - + {children} diff --git a/apps/web/lib/api/auth-cookies.ts b/apps/web/lib/api/auth-cookies.ts new file mode 100644 index 00000000..1e8908ba --- /dev/null +++ b/apps/web/lib/api/auth-cookies.ts @@ -0,0 +1,33 @@ +import type { InternalAxiosRequestConfig } from 'axios' +import type { NextResponse } from 'next/server' + +const AUTH_COOKIE_NAMES = ['accessToken', 'refreshToken'] as const + +const isClientSide = () => typeof window !== 'undefined' + +const setCookieHeader = async (request: InternalAxiosRequestConfig) => { + const { cookies } = await import('next/headers') + const cookieStore = await cookies() + const cookieHeader = cookieStore.toString() + + if (cookieHeader) { + request.headers.set('Cookie', cookieHeader) + } +} + +const clearAuthCookies = async () => { + const { cookies } = await import('next/headers') + const cookieStore = await cookies() + + AUTH_COOKIE_NAMES.forEach((cookieName) => { + cookieStore.delete(cookieName) + }) +} + +const appendSetCookieHeaders = (response: NextResponse, setCookies: string[]) => { + setCookies.forEach((cookie) => { + response.headers.append('Set-Cookie', cookie) + }) +} + +export { appendSetCookieHeaders, clearAuthCookies, isClientSide, setCookieHeader } diff --git a/apps/web/lib/api/client.ts b/apps/web/lib/api/client.ts index 215779aa..e90b32a9 100644 --- a/apps/web/lib/api/client.ts +++ b/apps/web/lib/api/client.ts @@ -1,7 +1,9 @@ +import { ROUTE_QUERY_PARAMS, ROUTES } from '@/shared/config/routes' import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios' -import { isTokenExpiredSoon, refreshSessionToken, useSessionStore } from '../session' +import { isClientSide, setCookieHeader } from './auth-cookies' import { axiosConfig } from './axios-config' +import { refreshAuthSession } from './refresh-auth-session' import { ApiError } from './types' import { toApiError } from './utils' @@ -13,14 +15,8 @@ const client = axios.create(axiosConfig) client.interceptors.request.use( async (request: InternalAxiosRequestConfig) => { - let token = useSessionStore.getState().accessToken - - if (token && isTokenExpiredSoon(token)) { - token = await refreshSessionToken() - } - - if (token) { - request.headers.set('Authorization', `Bearer ${token}`) + if (!isClientSide()) { + await setCookieHeader(request) } return request @@ -36,13 +32,20 @@ client.interceptors.response.use( if (error.response?.status === 401 && originalRequest && !originalRequest._retry) { originalRequest._retry = true - const token = await refreshSessionToken() - - if (token) { - originalRequest.headers.set('Authorization', `Bearer ${token}`) + const refreshResult = await refreshAuthSession() + if (refreshResult) { + if (!isClientSide()) { + await setCookieHeader(originalRequest) + } return client(originalRequest) } + + if (isClientSide()) { + const url = new URL(ROUTES.login, window.location.origin) + url.searchParams.set(ROUTE_QUERY_PARAMS.clearAuth, '1') + window.location.assign(url) + } } return Promise.reject(toApiError(error)) diff --git a/apps/web/lib/api/public-client.ts b/apps/web/lib/api/public-client.ts index de901f24..99c25eb7 100644 --- a/apps/web/lib/api/public-client.ts +++ b/apps/web/lib/api/public-client.ts @@ -1,10 +1,22 @@ -import axios from 'axios' +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios' +import { isClientSide, setCookieHeader } from './auth-cookies' import { axiosConfig } from './axios-config' import { toApiError } from './utils' const publicClient = axios.create(axiosConfig) +publicClient.interceptors.request.use( + async (request: InternalAxiosRequestConfig) => { + if (!isClientSide()) { + await setCookieHeader(request) + } + + return request + }, + (error: AxiosError) => Promise.reject(error), +) + publicClient.interceptors.response.use( (response) => response, (error) => Promise.reject(toApiError(error)), diff --git a/apps/web/lib/api/refresh-auth-session.ts b/apps/web/lib/api/refresh-auth-session.ts new file mode 100644 index 00000000..97bf602b --- /dev/null +++ b/apps/web/lib/api/refresh-auth-session.ts @@ -0,0 +1,24 @@ +import { requestAuthRefresh, type AuthRefreshResult } from './request-auth-refresh' + +let activeRefreshPromise: Promise | null = null + +const refreshAuthSession = async (): Promise => { + if (activeRefreshPromise) { + return activeRefreshPromise + } + + activeRefreshPromise = (async () => { + try { + return await requestAuthRefresh() + } catch { + return null + } finally { + activeRefreshPromise = null + } + })() + + return activeRefreshPromise +} + +export { refreshAuthSession } +export type { AuthRefreshResult } diff --git a/apps/web/lib/api/request-auth-refresh.ts b/apps/web/lib/api/request-auth-refresh.ts new file mode 100644 index 00000000..b54793f4 --- /dev/null +++ b/apps/web/lib/api/request-auth-refresh.ts @@ -0,0 +1,22 @@ +import type { AuthResponse } from '@repo/types' + +import { publicClient } from './public-client' + +type AuthRefreshResult = { + accessToken: string + setCookies: string[] +} + +const requestAuthRefresh = async (): Promise => { + const response = await publicClient.post('/auth/refresh') + + const setCookies = (response.headers['set-cookie'] as string[] | undefined) ?? [] + + return { + accessToken: response.data.accessToken, + setCookies, + } satisfies AuthRefreshResult +} + +export { requestAuthRefresh } +export type { AuthRefreshResult } diff --git a/apps/web/lib/query-client.ts b/apps/web/lib/query-client.ts index cd5c7f02..594188f4 100644 --- a/apps/web/lib/query-client.ts +++ b/apps/web/lib/query-client.ts @@ -1,5 +1,29 @@ -import { QueryClient } from '@tanstack/react-query' +import { isServer, QueryClient } from '@tanstack/react-query' + +const makeQueryClient = () => { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + }, + }, + }) +} + +let browserQueryClient: QueryClient | null = null + +const getQueryClient = () => { + if (isServer) { + return makeQueryClient() + } + + if (!browserQueryClient) { + browserQueryClient = makeQueryClient() + } + + return browserQueryClient +} const queryClient = new QueryClient() -export { queryClient } +export { queryClient, getQueryClient } diff --git a/apps/web/lib/session/auth-route-guard.tsx b/apps/web/lib/session/auth-route-guard.tsx deleted file mode 100644 index 2b285db7..00000000 --- a/apps/web/lib/session/auth-route-guard.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client' - -import { ROUTES } from '@/shared/config/routes' -import { useRouter } from 'next/navigation' -import { useEffect } from 'react' - -import { useSessionStore } from './session-store' - -interface Props { - children: React.ReactNode -} - -const AuthRouteGuard = ({ children }: Props) => { - const router = useRouter() - const status = useSessionStore((state) => state.status) - - useEffect(() => { - if (status === 'authenticated') { - router.replace(ROUTES.teams) - } - }, [router, status]) - - if (status !== 'guest') { - return null - } - - return children -} - -export { AuthRouteGuard } diff --git a/apps/web/lib/session/index.ts b/apps/web/lib/session/index.ts index 988624ed..5a2387eb 100644 --- a/apps/web/lib/session/index.ts +++ b/apps/web/lib/session/index.ts @@ -1,7 +1 @@ -export { useSessionStore } from './session-store' -export { refreshSessionToken } from './session-refresh' export { isTokenExpiredSoon } from './utils' - -export { SessionProvider } from './session-provider' -export { AuthRouteGuard } from './auth-route-guard' -export { ProtectedRouteGuard } from './protected-route-guard' diff --git a/apps/web/lib/session/protected-route-guard.tsx b/apps/web/lib/session/protected-route-guard.tsx deleted file mode 100644 index faff8729..00000000 --- a/apps/web/lib/session/protected-route-guard.tsx +++ /dev/null @@ -1,31 +0,0 @@ -'use client' - -import { buildLoginHref } from '@/shared/config/routes' -import { usePathname, useRouter } from 'next/navigation' -import { useEffect } from 'react' - -import { useSessionStore } from './session-store' - -interface Props { - children: React.ReactNode -} - -const ProtectedRouteGuard = ({ children }: Props) => { - const router = useRouter() - const pathname = usePathname() - const status = useSessionStore((state) => state.status) - - useEffect(() => { - if (status === 'guest') { - router.replace(buildLoginHref(pathname)) - } - }, [pathname, router, status]) - - if (status !== 'authenticated') { - return null - } - - return children -} - -export { ProtectedRouteGuard } diff --git a/apps/web/lib/session/refresh-access-token.ts b/apps/web/lib/session/refresh-access-token.ts deleted file mode 100644 index f8e87a3d..00000000 --- a/apps/web/lib/session/refresh-access-token.ts +++ /dev/null @@ -1,11 +0,0 @@ -'use client' - -import { publicClient } from '@/lib/api/public-client' - -import type { AuthResponse } from '@repo/types' - -export const refreshAccessToken = async () => { - const response = await publicClient.post('/auth/refresh') - - return response.data.accessToken -} diff --git a/apps/web/lib/session/session-init.ts b/apps/web/lib/session/session-init.ts deleted file mode 100644 index 0fb13cb9..00000000 --- a/apps/web/lib/session/session-init.ts +++ /dev/null @@ -1,33 +0,0 @@ -'use client' - -import { refreshAccessToken } from './refresh-access-token' -import { useSessionStore } from './session-store' - -let initPromise: Promise | null = null - -const initSession = async () => { - const store = useSessionStore.getState() - - if (store.isInitialized) { - return - } - - if (initPromise) { - return initPromise - } - - initPromise = (async () => { - try { - const accessToken = await refreshAccessToken() - useSessionStore.getState().setAuthenticated(accessToken) - } catch { - useSessionStore.getState().setGuest() - } finally { - initPromise = null - } - })() - - return initPromise -} - -export { initSession } diff --git a/apps/web/lib/session/session-provider.tsx b/apps/web/lib/session/session-provider.tsx deleted file mode 100644 index 5ca7154b..00000000 --- a/apps/web/lib/session/session-provider.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client' - -import { useEffect } from 'react' - -import { LoaderCircle } from '@repo/ui/icons' - -import { initSession } from './session-init' -import { useSessionStore } from './session-store' - -interface Props { - children: React.ReactNode -} - -const SessionProvider = ({ children }: Props) => { - const { status } = useSessionStore() - - useEffect(() => { - initSession() - }, []) - - if (status === 'unknown') { - return ( -
- -
- ) - } - - return children -} - -export { SessionProvider } diff --git a/apps/web/lib/session/session-refresh.ts b/apps/web/lib/session/session-refresh.ts deleted file mode 100644 index 3e63b572..00000000 --- a/apps/web/lib/session/session-refresh.ts +++ /dev/null @@ -1,29 +0,0 @@ -'use client' - -import { refreshAccessToken } from './refresh-access-token' -import { useSessionStore } from './session-store' - -let refreshPromise: Promise | null = null - -const refreshSessionToken = async () => { - if (refreshPromise) { - return refreshPromise - } - - refreshPromise = (async () => { - try { - const accessToken = await refreshAccessToken() - useSessionStore.getState().setAuthenticated(accessToken) - return accessToken - } catch { - useSessionStore.getState().clear() - return null - } finally { - refreshPromise = null - } - })() - - return refreshPromise -} - -export { refreshSessionToken } diff --git a/apps/web/lib/session/session-store.ts b/apps/web/lib/session/session-store.ts deleted file mode 100644 index 6a74ab39..00000000 --- a/apps/web/lib/session/session-store.ts +++ /dev/null @@ -1,41 +0,0 @@ -'use client' - -import { create } from 'zustand' - -export type SessionStatus = 'unknown' | 'authenticated' | 'guest' - -type SessionStore = { - status: SessionStatus - accessToken: string | null - isInitialized: boolean - setAuthenticated: (accessToken: string) => void - setGuest: () => void - clear: () => void -} - -export const useSessionStore = create((set) => ({ - status: 'unknown', - accessToken: null, - isInitialized: false, - - setAuthenticated: (accessToken) => - set({ - status: 'authenticated', - accessToken, - isInitialized: true, - }), - - setGuest: () => - set({ - status: 'guest', - accessToken: null, - isInitialized: true, - }), - - clear: () => - set({ - status: 'guest', - accessToken: null, - isInitialized: true, - }), -})) diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 7424d972..f6ad920a 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -6,21 +6,89 @@ import { } from '@/shared/config/routes' import { NextResponse, type NextRequest, type ProxyConfig } from 'next/server' -export function proxy(request: NextRequest) { +import { appendSetCookieHeaders, clearAuthCookies } from './lib/api/auth-cookies' +import { + refreshAuthSession, + type AuthRefreshResult, +} from './lib/api/refresh-auth-session' +import { isTokenExpiredSoon } from './lib/session' + +const createLoginRedirect = (request: NextRequest) => { const { pathname, search } = request.nextUrl - const accessToken = !!request.cookies.get('accessToken')?.value - const refreshToken = !!request.cookies.get('refreshToken')?.value + const url = new URL(ROUTES.login, request.url) - if (!refreshToken && isProtectedRoute(pathname)) { - const url = new URL(ROUTES.login, request.url) + if (!isAuthRoute(pathname)) { url.searchParams.set(ROUTE_QUERY_PARAMS.from, `${pathname}${search}`) - return NextResponse.redirect(url) } - if ((accessToken || refreshToken) && isAuthRoute(pathname)) { + return NextResponse.redirect(url) +} + +const shouldRefresh = (request: NextRequest) => { + const { pathname } = request.nextUrl + const accessToken = request.cookies.get('accessToken')?.value + const refreshToken = request.cookies.get('refreshToken')?.value + + const isAppRoute = isAuthRoute(pathname) || isProtectedRoute(pathname) + + return !!( + refreshToken && + isAppRoute && + (!accessToken || isTokenExpiredSoon(accessToken)) + ) +} + +const shouldClearAuthCookies = (request: NextRequest) => { + const { pathname, searchParams } = request.nextUrl + return Boolean( + pathname === ROUTES.login && searchParams.get(ROUTE_QUERY_PARAMS.clearAuth) === '1', + ) +} + +const handleRefresh = async (request: NextRequest) => { + const { pathname } = request.nextUrl + try { + const refreshResult: AuthRefreshResult | null = await refreshAuthSession() + + if (!refreshResult) { + throw new Error('Failed to refresh session') + } + + const response = isAuthRoute(pathname) + ? NextResponse.redirect(new URL(ROUTES.teams, request.url)) + : NextResponse.next() + + appendSetCookieHeaders(response, refreshResult.setCookies) + + return response + } catch { + await clearAuthCookies() + return createLoginRedirect(request) + } +} + +export async function proxy(request: NextRequest) { + const { pathname } = request.nextUrl + + if (shouldClearAuthCookies(request)) { + await clearAuthCookies() + return NextResponse.redirect(new URL(ROUTES.login, request.url)) + } + + if (shouldRefresh(request)) { + return handleRefresh(request) + } + + const accessToken = request.cookies.get('accessToken')?.value + + if (accessToken && isAuthRoute(pathname)) { return NextResponse.redirect(new URL(ROUTES.teams, request.url)) } + if (!accessToken && isProtectedRoute(pathname)) { + return createLoginRedirect(request) + } + return NextResponse.next() } diff --git a/apps/web/shared/config/routes.ts b/apps/web/shared/config/routes.ts index e1ca6e2e..153d5586 100644 --- a/apps/web/shared/config/routes.ts +++ b/apps/web/shared/config/routes.ts @@ -9,6 +9,7 @@ export const ROUTE_SEGMENTS = { export const ROUTE_QUERY_PARAMS = { from: 'from', + clearAuth: 'clearAuth', } as const export const ROUTES = { diff --git a/apps/web/widgets/main-layout/ui/header/profile-menu.tsx b/apps/web/widgets/main-layout/ui/header/profile-menu.tsx index c7495491..e2bc0f2c 100644 --- a/apps/web/widgets/main-layout/ui/header/profile-menu.tsx +++ b/apps/web/widgets/main-layout/ui/header/profile-menu.tsx @@ -1,7 +1,6 @@ 'use client' import { useLogout } from '@/hooks/api/use-auth' -import { useSessionStore } from '@/lib/session' import { ROUTES } from '@/shared/config/routes' import { useRouter } from 'next/navigation' import React from 'react' @@ -26,13 +25,10 @@ const currentUser = { const ProfileMenu = () => { const router = useRouter() const logoutMutation = useLogout() - const session = useSessionStore() const handleLogout = async () => { await logoutMutation.mutateAsync() - session.clear() - router.push(ROUTES.login) }