Initial commit: backend scaffold, auth, frontend login
- FastAPI + SQLAlchemy async + Alembic + Postgres backend - Auth: JWT access + rotating refresh tokens, argon2, roles, audit log - React 19 + Vite frontend: login page, protected route, auth context - Docker Compose: postgres, redis, migrate, api, worker (placeholder), frontend/nginx Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
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>
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
.login-page {
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 22px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-field label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.login-field input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.login-field input:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.login-field input:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
background: var(--color-danger-bg);
|
||||
border: 1px solid var(--color-danger-border);
|
||||
color: var(--color-danger);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
padding: 11px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.login-submit:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.login-submit:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-submit .spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-width: 2px;
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
border-top-color: #fff;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useId, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
|
||||
import { ApiError } from '@/api/client'
|
||||
import '@/features/auth/LoginPage.css'
|
||||
import { useAuth } from '@/features/auth/useAuth'
|
||||
|
||||
interface LocationState {
|
||||
from?: { pathname: string }
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const { status, login } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
const emailId = useId()
|
||||
const passwordId = useId()
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
|
||||
// Уже вошли — на странице входа делать нечего.
|
||||
if (status === 'authenticated') {
|
||||
const state = location.state as LocationState | null
|
||||
const redirectTo = state?.from?.pathname ?? '/'
|
||||
return <Navigate to={redirectTo} replace />
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setFormError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await login(email, password)
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу'
|
||||
setFormError(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<h1>lux_fiscal</h1>
|
||||
<p>Вход в систему фискализации заказов</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
{formError && (
|
||||
<div className="login-error" role="alert">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="login-field">
|
||||
<label htmlFor={emailId}>Email</label>
|
||||
<input
|
||||
id={emailId}
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
disabled={submitting}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="login-field">
|
||||
<label htmlFor={passwordId}>Пароль</label>
|
||||
<input
|
||||
id={passwordId}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
disabled={submitting}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="login-submit" disabled={submitting}>
|
||||
{submitting && <span className="spinner" aria-hidden="true" />}
|
||||
{submitting ? 'Входим…' : 'Войти'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
|
||||
import { useAuth } from '@/features/auth/useAuth'
|
||||
|
||||
/** Пропускает дальше только аутентифицированных; остальных отправляет на /login. */
|
||||
export function ProtectedRoute() {
|
||||
const { status } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="page-center">
|
||||
<span className="spinner" aria-label="Загрузка" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useContext } from 'react'
|
||||
|
||||
import { AuthContext } from '@/features/auth/AuthContext'
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useAuth должен вызываться внутри <AuthProvider>')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
Reference in New Issue
Block a user