Первоначальная версия VidConf
This commit is contained in:
72
frontend/src/auth/AuthProvider.tsx
Normal file
72
frontend/src/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { getMe, login as apiLogin, logout as apiLogout, type CurrentUser } from '@/api/auth'
|
||||
import { authStore } from '@/auth/authStore'
|
||||
import { refreshAccessToken } from '@/api/client'
|
||||
import { AuthContext, type AuthContextValue, type AuthStatus } from '@/auth/authContext'
|
||||
|
||||
/**
|
||||
* Провайдер сессии пользователя.
|
||||
* При монтировании приложения пытается восстановить сессию через
|
||||
* silent-refresh (httpOnly refresh-cookie), т.к. access-токен в памяти
|
||||
* теряется при перезагрузке страницы.
|
||||
*/
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<CurrentUser | null>(null)
|
||||
const [status, setStatus] = useState<AuthStatus>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function bootstrap() {
|
||||
const restored = await refreshAccessToken()
|
||||
if (cancelled) return
|
||||
if (!restored) {
|
||||
setStatus('unauthenticated')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const me = await getMe()
|
||||
if (cancelled) return
|
||||
setUser(me)
|
||||
setStatus('authenticated')
|
||||
} catch {
|
||||
if (!cancelled) setStatus('unauthenticated')
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const token = await apiLogin(email, password)
|
||||
authStore.setAccessToken(token.access_token)
|
||||
const me = await getMe()
|
||||
setUser(me)
|
||||
setStatus('authenticated')
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await apiLogout()
|
||||
} finally {
|
||||
authStore.setAccessToken(null)
|
||||
setUser(null)
|
||||
setStatus('unauthenticated')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
const me = await getMe()
|
||||
setUser(me)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({ user, status, login, logout, refreshUser }),
|
||||
[user, status, login, logout, refreshUser],
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
18
frontend/src/auth/RequireAdmin.tsx
Normal file
18
frontend/src/auth/RequireAdmin.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
|
||||
/**
|
||||
* Route-guard админки: доступ только у пользователей с ролью
|
||||
* `admin`. Оборачивается ВНУТРИ `RequireAuth` (сессия уже гарантированно
|
||||
* восстановлена и `user` заполнен) — без сессии сюда не попасть.
|
||||
*/
|
||||
export function RequireAdmin({ children }: { children: ReactNode }) {
|
||||
const { user } = useAuth()
|
||||
|
||||
if (user?.role !== 'admin') {
|
||||
return <Navigate to="/lobby" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
27
frontend/src/auth/RequireAuth.tsx
Normal file
27
frontend/src/auth/RequireAuth.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
|
||||
/**
|
||||
* Обёртка для приватных маршрутов: пока идёт восстановление сессии —
|
||||
* показывает лоадер, без сессии — редиректит на /login (сохраняя исходный
|
||||
* путь, чтобы вернуться туда после входа).
|
||||
*/
|
||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { status } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="app-loader" role="status" aria-live="polite">
|
||||
Загрузка сессии…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
26
frontend/src/auth/authContext.ts
Normal file
26
frontend/src/auth/authContext.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createContext } from 'react'
|
||||
import type { CurrentUser } from '@/api/auth'
|
||||
|
||||
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated'
|
||||
|
||||
export interface AuthContextValue {
|
||||
user: CurrentUser | null
|
||||
status: AuthStatus
|
||||
/** Вход по email/паролю: сохраняет access-токен и загружает профиль. */
|
||||
login: (email: string, password: string) => Promise<void>
|
||||
/** Выход: инвалидирует refresh-сессию на backend и очищает локальное состояние. */
|
||||
logout: () => Promise<void>
|
||||
/**
|
||||
* Перечитать профиль (`GET /users/me`) и обновить контекст — вызывается
|
||||
* после правки профиля/аватара (страница `/profile`), чтобы имя и
|
||||
* аватар в топбаре обновились без перезагрузки страницы.
|
||||
*/
|
||||
refreshUser: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Контекст сессии пользователя. Вынесен в отдельный файл (не в AuthProvider.tsx),
|
||||
* чтобы файл компонента экспортировал только React-компонент — это нужно
|
||||
* react-refresh для корректного fast refresh в dev-режиме.
|
||||
*/
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
41
frontend/src/auth/authStore.ts
Normal file
41
frontend/src/auth/authStore.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
/**
|
||||
* Модуль-стор access-токена JWT.
|
||||
*
|
||||
* Токен хранится ТОЛЬКО в памяти процесса (в переменной модуля), никогда —
|
||||
* в localStorage/sessionStorage: это защищает его от чтения через XSS.
|
||||
* Refresh-токен — httpOnly cookie, им управляет браузер, здесь он не виден
|
||||
* и не хранится.
|
||||
*
|
||||
* При перезагрузке страницы access-токен теряется — сессия восстанавливается
|
||||
* через silent-refresh в AuthProvider при старте приложения.
|
||||
*/
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
let accessToken: string | null = null
|
||||
const listeners = new Set<Listener>()
|
||||
|
||||
function emitChange(): void {
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
export const authStore = {
|
||||
getAccessToken(): string | null {
|
||||
return accessToken
|
||||
},
|
||||
setAccessToken(token: string | null): void {
|
||||
accessToken = token
|
||||
emitChange()
|
||||
},
|
||||
subscribe(listener: Listener): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
}
|
||||
|
||||
/** Реактивный доступ к текущему access-токену (для компонентов, которым это нужно). */
|
||||
export function useAccessToken(): string | null {
|
||||
return useSyncExternalStore(authStore.subscribe, authStore.getAccessToken)
|
||||
}
|
||||
9
frontend/src/auth/useAuth.ts
Normal file
9
frontend/src/auth/useAuth.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useContext } from 'react'
|
||||
import { AuthContext, type AuthContextValue } from '@/auth/authContext'
|
||||
|
||||
/** Хук доступа к текущей сессии пользователя. Должен использоваться внутри AuthProvider. */
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth должен использоваться внутри <AuthProvider>')
|
||||
return ctx
|
||||
}
|
||||
Reference in New Issue
Block a user