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:
2026-09-22 15:07:15 +03:00
co-authored by Claude Sonnet 5
commit 2664cb8213
71 changed files with 4999 additions and 0 deletions
@@ -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>
}
+111
View File
@@ -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;
}
+97
View File
@@ -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 />
}
+11
View File
@@ -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
}