Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 30 additions & 36 deletions apps/web/app/(auth)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -15,43 +13,39 @@ export default function AuthLayout({ children }: Props) {
}

return (
<SessionProvider>
<AuthRouteGuard>
<div className='relative min-h-screen overflow-hidden bg-background text-foreground'>
<div className='pointer-events-none absolute inset-0'>
<div className='absolute left-0 top-0 h-[320px] w-[320px] rounded-full bg-primary/14 blur-[110px]' />
<div className='absolute bottom-[-120px] right-[-40px] h-[320px] w-[320px] rounded-full bg-accent/10 blur-[120px]' />
</div>
<div className='relative min-h-screen overflow-hidden bg-background text-foreground'>
<div className='pointer-events-none absolute inset-0'>
<div className='absolute left-0 top-0 h-[320px] w-[320px] rounded-full bg-primary/14 blur-[110px]' />
<div className='absolute bottom-[-120px] right-[-40px] h-[320px] w-[320px] rounded-full bg-accent/10 blur-[120px]' />
</div>

<div className='relative mx-auto flex min-h-screen w-full max-w-[1180px] flex-col px-4 py-6 sm:px-6'>
<div className='flex items-center justify-between gap-3'>
<Link
href={ROUTES.home}
className='inline-flex items-center gap-3 rounded-full border border-border/70 bg-card/55 px-3 py-2 transition-colors hover:bg-card'
>
<div className='flex h-10 w-10 items-center justify-center rounded-xl border border-primary/30 bg-primary/12 text-sm font-semibold text-primary'>
TT
</div>
<div className='hidden sm:block'>
<p className='text-sm font-medium tracking-[0.24em] text-primary/90'>
TRACKER TASK
</p>
<p className='text-xs text-muted-foreground'>Вернуться на главную</p>
</div>
</Link>

<Link
href={ROUTES.home}
className='inline-flex items-center rounded-full border border-border/70 bg-background/70 px-4 py-2 text-sm text-muted-foreground transition-colors hover:border-primary/25 hover:text-foreground'
>
На главную
</Link>
<div className='relative mx-auto flex min-h-screen w-full max-w-[1180px] flex-col px-4 py-6 sm:px-6'>
<div className='flex items-center justify-between gap-3'>
<Link
href={ROUTES.home}
className='inline-flex items-center gap-3 rounded-full border border-border/70 bg-card/55 px-3 py-2 transition-colors hover:bg-card'
>
<div className='flex h-10 w-10 items-center justify-center rounded-xl border border-primary/30 bg-primary/12 text-sm font-semibold text-primary'>
TT
</div>
<div className='hidden sm:block'>
<p className='text-sm font-medium tracking-[0.24em] text-primary/90'>
TRACKER TASK
</p>
<p className='text-xs text-muted-foreground'>Вернуться на главную</p>
</div>
</Link>

<div className='flex flex-1 items-center justify-center py-8'>{children}</div>
</div>
<Link
href={ROUTES.home}
className='inline-flex items-center rounded-full border border-border/70 bg-background/70 px-4 py-2 text-sm text-muted-foreground transition-colors hover:border-primary/25 hover:text-foreground'
>
На главную
</Link>
</div>
</AuthRouteGuard>
</SessionProvider>

<div className='flex flex-1 items-center justify-center py-8'>{children}</div>
</div>
</div>
)
}
8 changes: 4 additions & 4 deletions apps/web/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions apps/web/app/(auth)/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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)
Expand Down
12 changes: 4 additions & 8 deletions apps/web/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -9,11 +7,9 @@ interface Props {

export default function DashboardLayout({ children, modal }: Props) {
return (
<SessionProvider>
<ProtectedRouteGuard>
<MainLayout>{children}</MainLayout>
{modal}
</ProtectedRouteGuard>
</SessionProvider>
<>
<MainLayout>{children}</MainLayout>
{modal}
</>
)
}
2 changes: 1 addition & 1 deletion apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default function RootLayout({
}>) {
return (
<html lang='ru' className='dark' suppressHydrationWarning>
<body>
<body suppressHydrationWarning>
<Providers>{children}</Providers>
<Toaster />
</body>
Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<QueryClientProvider client={queryClient}>
<QueryClientProvider client={getQueryClient()}>
<ThemeSync />
{children}
<ReactQueryDevtools initialIsOpen={false} />
Expand Down
33 changes: 33 additions & 0 deletions apps/web/lib/api/auth-cookies.ts
Original file line number Diff line number Diff line change
@@ -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 }
29 changes: 16 additions & 13 deletions apps/web/lib/api/client.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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
Expand All @@ -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))
Expand Down
14 changes: 13 additions & 1 deletion apps/web/lib/api/public-client.ts
Original file line number Diff line number Diff line change
@@ -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)),
Expand Down
24 changes: 24 additions & 0 deletions apps/web/lib/api/refresh-auth-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { requestAuthRefresh, type AuthRefreshResult } from './request-auth-refresh'

let activeRefreshPromise: Promise<AuthRefreshResult | null> | null = null

const refreshAuthSession = async (): Promise<AuthRefreshResult | null> => {
if (activeRefreshPromise) {
return activeRefreshPromise
}

activeRefreshPromise = (async () => {
try {
return await requestAuthRefresh()
} catch {
return null
} finally {
activeRefreshPromise = null
}
})()

return activeRefreshPromise
}

export { refreshAuthSession }
export type { AuthRefreshResult }
22 changes: 22 additions & 0 deletions apps/web/lib/api/request-auth-refresh.ts
Original file line number Diff line number Diff line change
@@ -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<AuthRefreshResult> => {
const response = await publicClient.post<AuthResponse>('/auth/refresh')

const setCookies = (response.headers['set-cookie'] as string[] | undefined) ?? []

return {
accessToken: response.data.accessToken,
setCookies,
} satisfies AuthRefreshResult
}

export { requestAuthRefresh }
export type { AuthRefreshResult }
28 changes: 26 additions & 2 deletions apps/web/lib/query-client.ts
Original file line number Diff line number Diff line change
@@ -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 }
Loading
Loading