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

19
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { Suspense } from "react";
import { RouterProvider } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "@/components/shared/theme-provider";
import { queryClient } from "@/lib/query-client";
import { router } from "@/routes";
import "@/i18n";
export function App() {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<Suspense fallback={<div className="flex h-screen items-center justify-center">Loading...</div>}>
<RouterProvider router={router} />
</Suspense>
</ThemeProvider>
</QueryClientProvider>
);
}

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;

View File

@@ -0,0 +1,103 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
import { login } from "@/api/auth";
export function LoginForm() {
const { t } = useTranslation();
const navigate = useNavigate();
const setAuth = useAuthStore((s) => s.setAuth);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const data = await login(email, password, rememberMe);
setAuth(data.user, data.access_token, data.refresh_token);
navigate("/");
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
t("auth.errors.invalidCredentials");
setError(msg);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
{t("auth.email")}
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder="name@example.com"
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
{t("auth.password")}
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="flex items-center gap-2">
<input
id="remember"
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
<label htmlFor="remember" className="text-sm text-muted-foreground">
{t("auth.rememberMe")}
</label>
</div>
<button
type="submit"
disabled={loading}
className="inline-flex h-10 w-full items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{loading ? t("common.loading") : t("auth.login")}
</button>
<p className="text-center text-sm text-muted-foreground">
{t("auth.noAccount")}{" "}
<Link to="/register" className="text-primary hover:underline">
{t("auth.register")}
</Link>
</p>
</form>
);
}

View File

@@ -0,0 +1,150 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
import { register } from "@/api/auth";
export function RegisterForm() {
const { t } = useTranslation();
const navigate = useNavigate();
const setAuth = useAuthStore((s) => s.setAuth);
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [fullName, setFullName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (password.length < 8) {
setError(t("auth.errors.passwordMinLength"));
return;
}
if (password !== confirmPassword) {
setError(t("auth.errors.passwordMismatch"));
return;
}
if (!/^[a-zA-Z0-9_-]{3,50}$/.test(username)) {
setError(t("auth.errors.usernameFormat"));
return;
}
setLoading(true);
try {
const data = await register(email, username, password, fullName || undefined);
setAuth(data.user, data.access_token, data.refresh_token);
navigate("/");
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
t("auth.errors.emailExists");
setError(msg);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
{t("auth.email")}
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder="name@example.com"
/>
</div>
<div className="space-y-2">
<label htmlFor="username" className="text-sm font-medium">
{t("auth.username")}
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="space-y-2">
<label htmlFor="fullName" className="text-sm font-medium">
{t("auth.fullName")}
</label>
<input
id="fullName"
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
{t("auth.password")}
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="space-y-2">
<label htmlFor="confirmPassword" className="text-sm font-medium">
{t("auth.confirmPassword")}
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<button
type="submit"
disabled={loading}
className="inline-flex h-10 w-full items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{loading ? t("common.loading") : t("auth.register")}
</button>
<p className="text-center text-sm text-muted-foreground">
{t("auth.hasAccount")}{" "}
<Link to="/login" className="text-primary hover:underline">
{t("auth.login")}
</Link>
</p>
</form>
);
}

View File

@@ -0,0 +1,17 @@
import { Outlet } from "react-router-dom";
import { Sidebar } from "./sidebar";
import { Header } from "./header";
export function AppLayout() {
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Menu, Sun, Moon, LogOut, User } from "lucide-react";
import { useAuthStore } from "@/stores/auth-store";
import { useUIStore } from "@/stores/ui-store";
import { LanguageToggle } from "@/components/shared/language-toggle";
import { logout as logoutApi } from "@/api/auth";
export function Header() {
const { t } = useTranslation();
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const { refreshToken, clearAuth } = useAuthStore();
const { theme, setTheme, toggleSidebar } = useUIStore();
const handleLogout = async () => {
try {
if (refreshToken) await logoutApi(refreshToken);
} finally {
clearAuth();
navigate("/login");
}
};
const toggleTheme = () => {
if (theme === "light") setTheme("dark");
else if (theme === "dark") setTheme("system");
else setTheme("light");
};
return (
<header className="flex h-14 items-center gap-4 border-b bg-card px-4">
<button
onClick={toggleSidebar}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Menu className="h-5 w-5" />
</button>
<div className="flex-1" />
<LanguageToggle />
<button
onClick={toggleTheme}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
title={`Theme: ${theme}`}
>
{theme === "dark" ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />}
</button>
<div className="flex items-center gap-2 border-l pl-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
<User className="h-4 w-4" />
</div>
<span className="hidden text-sm font-medium sm:block">
{user?.full_name || user?.username}
</span>
<button
onClick={handleLogout}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-destructive transition-colors"
title={t("layout.logout")}
>
<LogOut className="h-4 w-4" />
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,99 @@
import { useTranslation } from "react-i18next";
import { NavLink } from "react-router-dom";
import {
LayoutDashboard,
MessageSquare,
FileText,
Brain,
Bell,
Shield,
} from "lucide-react";
import { useAuthStore } from "@/stores/auth-store";
import { useUIStore } from "@/stores/ui-store";
import { cn } from "@/lib/utils";
const navItems = [
{ key: "dashboard", to: "/", icon: LayoutDashboard, enabled: true },
{ key: "chats", to: "/chat", icon: MessageSquare, enabled: false },
{ key: "documents", to: "/documents", icon: FileText, enabled: false },
{ key: "memory", to: "/memory", icon: Brain, enabled: false },
{ key: "notifications", to: "/notifications", icon: Bell, enabled: false },
];
export function Sidebar() {
const { t } = useTranslation();
const user = useAuthStore((s) => s.user);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
return (
<aside
className={cn(
"flex h-full flex-col border-r bg-card transition-all duration-300",
sidebarOpen ? "w-60" : "w-16"
)}
>
<div className="flex h-14 items-center border-b px-4">
{sidebarOpen && (
<span className="text-lg font-semibold text-primary">AI Assistant</span>
)}
{!sidebarOpen && (
<span className="text-lg font-semibold text-primary mx-auto">AI</span>
)}
</div>
<nav className="flex-1 space-y-1 p-2">
{navItems.map((item) => {
const Icon = item.icon;
if (!item.enabled) {
return (
<div
key={item.key}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-muted-foreground/50 cursor-not-allowed",
!sidebarOpen && "justify-center px-2"
)}
>
<Icon className="h-5 w-5 shrink-0" />
{sidebarOpen && <span>{t(`layout.${item.key}`)}</span>}
</div>
);
}
return (
<NavLink
key={item.key}
to={item.to}
end
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
!sidebarOpen && "justify-center px-2"
)
}
>
<Icon className="h-5 w-5 shrink-0" />
{sidebarOpen && <span>{t(`layout.${item.key}`)}</span>}
</NavLink>
);
})}
</nav>
{user?.role === "admin" && (
<div className="border-t p-2">
<div
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-muted-foreground/50 cursor-not-allowed",
!sidebarOpen && "justify-center px-2"
)}
>
<Shield className="h-5 w-5 shrink-0" />
{sidebarOpen && <span>{t("layout.admin")}</span>}
</div>
</div>
)}
</aside>
);
}

View File

@@ -0,0 +1,22 @@
import { useTranslation } from "react-i18next";
import { Languages } from "lucide-react";
export function LanguageToggle() {
const { i18n } = useTranslation();
const toggle = () => {
const next = i18n.language === "ru" ? "en" : "ru";
i18n.changeLanguage(next);
};
return (
<button
onClick={toggle}
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title={i18n.language === "ru" ? "Switch to English" : "Переключить на русский"}
>
<Languages className="h-4 w-4" />
<span className="uppercase">{i18n.language === "ru" ? "en" : "ru"}</span>
</button>
);
}

View File

@@ -0,0 +1,12 @@
import { Navigate, Outlet } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
export function ProtectedRoute() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
}

View File

@@ -0,0 +1,20 @@
import { useEffect } from "react";
import { useUIStore } from "@/stores/ui-store";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const theme = useUIStore((s) => s.theme);
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
root.classList.add(systemDark ? "dark" : "light");
} else {
root.classList.add(theme);
}
}, [theme]);
return <>{children}</>;
}

25
frontend/src/i18n.ts Normal file
View File

@@ -0,0 +1,25 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import HttpBackend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";
i18n
.use(HttpBackend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en",
supportedLngs: ["en", "ru"],
interpolation: {
escapeValue: false,
},
backend: {
loadPath: "/locales/{{lng}}/translation.json",
},
detection: {
order: ["localStorage", "navigator"],
caches: ["localStorage"],
},
});
export default i18n;

59
frontend/src/index.css Normal file
View File

@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 210 40% 98%;
--foreground: 222 47% 11%;
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--primary: 198 70% 40%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222 47% 11%;
--muted: 210 40% 96%;
--muted-foreground: 215 16% 47%;
--accent: 210 40% 96%;
--accent-foreground: 222 47% 11%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 198 70% 40%;
--radius: 0.75rem;
}
.dark {
--background: 222 47% 11%;
--foreground: 210 40% 98%;
--card: 222 47% 13%;
--card-foreground: 210 40% 98%;
--popover: 222 47% 13%;
--popover-foreground: 210 40% 98%;
--primary: 198 70% 50%;
--primary-foreground: 222 47% 11%;
--secondary: 217 33% 17%;
--secondary-foreground: 210 40% 98%;
--muted: 217 33% 17%;
--muted-foreground: 215 20% 65%;
--accent: 217 33% 17%;
--accent-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--border: 217 33% 17%;
--input: 217 33% 17%;
--ring: 198 70% 50%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,11 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import { useAuthStore } from "@/stores/auth-store";
export function DashboardPage() {
const { t } = useTranslation();
const user = useAuthStore((s) => s.user);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{t("dashboard.welcome", { name: user?.full_name || user?.username })}
</h1>
<p className="text-muted-foreground">{t("dashboard.subtitle")}</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="rounded-xl border bg-card p-6 shadow-sm">
<h3 className="text-sm font-medium text-muted-foreground">{t("layout.chats")}</h3>
<p className="mt-2 text-3xl font-bold">0</p>
</div>
<div className="rounded-xl border bg-card p-6 shadow-sm">
<h3 className="text-sm font-medium text-muted-foreground">{t("layout.documents")}</h3>
<p className="mt-2 text-3xl font-bold">0</p>
</div>
<div className="rounded-xl border bg-card p-6 shadow-sm">
<h3 className="text-sm font-medium text-muted-foreground">{t("layout.notifications")}</h3>
<p className="mt-2 text-3xl font-bold">0</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuthStore } from "@/stores/auth-store";
import { LoginForm } from "@/components/auth/login-form";
import { LanguageToggle } from "@/components/shared/language-toggle";
export function LoginPage() {
const { t } = useTranslation();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
if (isAuthenticated) return <Navigate to="/" replace />;
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="absolute right-4 top-4">
<LanguageToggle />
</div>
<div className="w-full max-w-md space-y-6 rounded-xl border bg-card p-8 shadow-sm">
<div className="space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">{t("auth.loginTitle")}</h1>
<p className="text-sm text-muted-foreground">{t("auth.loginSubtitle")}</p>
</div>
<LoginForm />
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
export function NotFoundPage() {
const { t } = useTranslation();
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background">
<h1 className="text-6xl font-bold text-muted-foreground">404</h1>
<p className="mt-4 text-lg text-muted-foreground">{t("common.notFound")}</p>
<Link
to="/"
className="mt-6 inline-flex h-10 items-center justify-center rounded-md bg-primary px-6 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
{t("common.goHome")}
</Link>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuthStore } from "@/stores/auth-store";
import { RegisterForm } from "@/components/auth/register-form";
import { LanguageToggle } from "@/components/shared/language-toggle";
export function RegisterPage() {
const { t } = useTranslation();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
if (isAuthenticated) return <Navigate to="/" replace />;
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="absolute right-4 top-4">
<LanguageToggle />
</div>
<div className="w-full max-w-md space-y-6 rounded-xl border bg-card p-8 shadow-sm">
<div className="space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">{t("auth.registerTitle")}</h1>
<p className="text-sm text-muted-foreground">{t("auth.registerSubtitle")}</p>
</div>
<RegisterForm />
</div>
</div>
);
}

33
frontend/src/routes.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { createBrowserRouter } from "react-router-dom";
import { ProtectedRoute } from "@/components/shared/protected-route";
import { AppLayout } from "@/components/layout/app-layout";
import { LoginPage } from "@/pages/login";
import { RegisterPage } from "@/pages/register";
import { DashboardPage } from "@/pages/dashboard";
import { NotFoundPage } from "@/pages/not-found";
export const router = createBrowserRouter([
{
path: "/login",
element: <LoginPage />,
},
{
path: "/register",
element: <RegisterPage />,
},
{
element: <ProtectedRoute />,
children: [
{
element: <AppLayout />,
children: [
{ index: true, element: <DashboardPage /> },
],
},
],
},
{
path: "*",
element: <NotFoundPage />,
},
]);

View File

@@ -0,0 +1,41 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { UserResponse } from "@/api/auth";
interface AuthState {
user: UserResponse | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
setAuth: (user: UserResponse, accessToken: string, refreshToken: string) => void;
setTokens: (accessToken: string, refreshToken: string) => void;
setUser: (user: UserResponse) => void;
clearAuth: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
setAuth: (user, accessToken, refreshToken) =>
set({ user, accessToken, refreshToken, isAuthenticated: true }),
setTokens: (accessToken, refreshToken) =>
set({ accessToken, refreshToken }),
setUser: (user) => set({ user }),
clearAuth: () =>
set({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false }),
}),
{
name: "auth-storage",
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated,
}),
}
)
);

View File

@@ -0,0 +1,28 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
type Theme = "light" | "dark" | "system";
interface UIState {
sidebarOpen: boolean;
theme: Theme;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setTheme: (theme: Theme) => void;
}
export const useUIStore = create<UIState>()(
persist(
(set) => ({
sidebarOpen: true,
theme: "system",
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setTheme: (theme) => set({ theme }),
}),
{
name: "ui-storage",
partialize: (state) => ({ theme: state.theme }),
}
)
);

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />