Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View 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>
}

View 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}</>
}

View 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}</>
}

View 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)

View 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)
}

View 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
}