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:
68
frontend/src/api/auth.ts
Normal file
68
frontend/src/api/auth.ts
Normal 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;
|
||||
}
|
||||
83
frontend/src/api/client.ts
Normal file
83
frontend/src/api/client.ts
Normal 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;
|
||||
Reference in New Issue
Block a user