- All frontend pages, labels, notices and errors; html lang=uk, uk-UA money format - Brand "Assistant System" in the top bar and page title - Backend error details returned to the UI (auth, orders, receipts, cash registers, Checkbox/CRM/NP errors) and CLI output - Tests updated for the new messages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
96 lines
3.4 KiB
TypeScript
96 lines
3.4 KiB
TypeScript
import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
|
|
import type { ReactNode } from 'react'
|
|
|
|
import * as authApi from '@/api/auth'
|
|
import { ApiError } from '@/api/client'
|
|
import { clearTokens, getRefreshToken, setTokens } from '@/api/tokenStore'
|
|
import type { User } from '@/api/types'
|
|
|
|
type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated'
|
|
|
|
interface AuthContextValue {
|
|
status: AuthStatus
|
|
user: User | null
|
|
/** null, пока запрос не завершится (успехом или ошибкой). */
|
|
error: string | null
|
|
login: (email: string, password: string) => Promise<void>
|
|
logout: () => Promise<void>
|
|
}
|
|
|
|
// oxlint-disable-next-line react/only-export-components -- контекст и провайдер экспортируются вместе намеренно
|
|
export const AuthContext = createContext<AuthContextValue | null>(null)
|
|
|
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
// Если refresh-токена нет, статус известен сразу же, без похода в сеть —
|
|
// вычисляем его в инициализаторе, а не синхронным setState внутри эффекта.
|
|
const [status, setStatus] = useState<AuthStatus>(() =>
|
|
getRefreshToken() ? 'loading' : 'unauthenticated',
|
|
)
|
|
const [user, setUser] = useState<User | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// При открытии приложения пытаемся восстановить сессию по refresh-токену
|
|
// из localStorage — иначе каждая перезагрузка страницы требовала бы входа.
|
|
useEffect(() => {
|
|
const existingRefreshToken = getRefreshToken()
|
|
if (!existingRefreshToken) return
|
|
|
|
let cancelled = false
|
|
|
|
void (async () => {
|
|
try {
|
|
const pair = await authApi.refresh(existingRefreshToken)
|
|
setTokens(pair.access_token, pair.refresh_token)
|
|
const me = await authApi.fetchMe()
|
|
if (cancelled) return
|
|
setUser(me)
|
|
setStatus('authenticated')
|
|
} catch {
|
|
if (cancelled) return
|
|
clearTokens()
|
|
setStatus('unauthenticated')
|
|
}
|
|
})()
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
const login = useCallback(async (email: string, password: string) => {
|
|
setError(null)
|
|
try {
|
|
const result = await authApi.login(email, password)
|
|
setTokens(result.access_token, result.refresh_token)
|
|
setUser(result.user)
|
|
setStatus('authenticated')
|
|
} catch (err) {
|
|
const message = err instanceof ApiError ? err.message : 'Не вдалося підключитися до сервера'
|
|
setError(message)
|
|
throw err
|
|
}
|
|
}, [])
|
|
|
|
const logout = useCallback(async () => {
|
|
const refreshToken = getRefreshToken()
|
|
if (refreshToken) {
|
|
// Best-effort: даже если сервер недоступен, локально выходим всё равно.
|
|
try {
|
|
await authApi.logout(refreshToken)
|
|
} catch {
|
|
// Токен и так будет забыт локально ниже.
|
|
}
|
|
}
|
|
clearTokens()
|
|
setUser(null)
|
|
setStatus('unauthenticated')
|
|
}, [])
|
|
|
|
const value = useMemo<AuthContextValue>(
|
|
() => ({ status, user, error, login, logout }),
|
|
[status, user, error, login, logout],
|
|
)
|
|
|
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
|
}
|