Phase 1: Foundation — backend auth, frontend shell, Docker setup

Backend (FastAPI):
- App factory with async SQLAlchemy 2.0 + PostgreSQL
- Alembic migration for users and sessions tables
- JWT auth (access + refresh tokens, bcrypt passwords)
- Auth endpoints: register, login, refresh, logout, me
- Admin seed script, role-based access deps

Frontend (React + TypeScript):
- Vite + Tailwind CSS + shadcn/ui theme (health-oriented palette)
- i18n with English and Russian translations
- Zustand auth/UI stores with localStorage persistence
- Axios client with automatic token refresh on 401
- Login/register pages, protected routing
- App layout: collapsible sidebar, header with theme/language toggles
- Dashboard with placeholder stats

Infrastructure:
- Docker Compose (postgres, backend, frontend, nginx)
- Nginx reverse proxy with WebSocket support
- Dev override with hot reload

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 12:25:02 +03:00
parent 5bdc296172
commit 7c752cae6b
75 changed files with 7706 additions and 2 deletions

68
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,68 @@
import api from "./client";
export interface UserResponse {
id: string;
email: string;
username: string;
full_name: string | null;
role: string;
is_active: boolean;
created_at: string;
}
export interface AuthResponse {
user: UserResponse;
access_token: string;
refresh_token: string;
token_type: string;
}
export interface TokenResponse {
access_token: string;
refresh_token: string;
token_type: string;
}
export async function login(
email: string,
password: string,
remember_me: boolean
): Promise<AuthResponse> {
const { data } = await api.post<AuthResponse>("/auth/login", {
email,
password,
remember_me,
});
return data;
}
export async function register(
email: string,
username: string,
password: string,
full_name?: string
): Promise<AuthResponse> {
const { data } = await api.post<AuthResponse>("/auth/register", {
email,
username,
password,
full_name: full_name || undefined,
});
return data;
}
export async function refresh(refreshToken: string): Promise<TokenResponse> {
const { data } = await api.post<TokenResponse>("/auth/refresh", {
refresh_token: refreshToken,
});
return data;
}
export async function logout(refreshToken: string): Promise<void> {
await api.post("/auth/logout", { refresh_token: refreshToken });
}
export async function getMe(): Promise<UserResponse> {
const { data } = await api.get<UserResponse>("/auth/me");
return data;
}

View File

@@ -0,0 +1,83 @@
import axios from "axios";
import { useAuthStore } from "@/stores/auth-store";
const api = axios.create({
baseURL: "/api/v1",
headers: { "Content-Type": "application/json" },
});
// Attach access token
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Auto-refresh on 401
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: unknown) => void;
}> = [];
function processQueue(error: unknown, token: string | null) {
failedQueue.forEach(({ resolve, reject }) => {
if (error) reject(error);
else resolve(token!);
});
failedQueue = [];
}
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token: string) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(api(originalRequest));
},
reject,
});
});
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = useAuthStore.getState().refreshToken;
if (!refreshToken) {
useAuthStore.getState().clearAuth();
window.location.href = "/login";
return Promise.reject(error);
}
try {
const { data } = await axios.post("/api/v1/auth/refresh", {
refresh_token: refreshToken,
});
useAuthStore.getState().setTokens(data.access_token, data.refresh_token);
processQueue(null, data.access_token);
originalRequest.headers.Authorization = `Bearer ${data.access_token}`;
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
useAuthStore.getState().clearAuth();
window.location.href = "/login";
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
);
export default api;