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:
19
frontend/src/App.tsx
Normal file
19
frontend/src/App.tsx
Normal 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
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;
|
||||
103
frontend/src/components/auth/login-form.tsx
Normal file
103
frontend/src/components/auth/login-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
frontend/src/components/auth/register-form.tsx
Normal file
150
frontend/src/components/auth/register-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
frontend/src/components/layout/app-layout.tsx
Normal file
17
frontend/src/components/layout/app-layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
frontend/src/components/layout/header.tsx
Normal file
69
frontend/src/components/layout/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
frontend/src/components/layout/sidebar.tsx
Normal file
99
frontend/src/components/layout/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/shared/language-toggle.tsx
Normal file
22
frontend/src/components/shared/language-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
frontend/src/components/shared/protected-route.tsx
Normal file
12
frontend/src/components/shared/protected-route.tsx
Normal 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 />;
|
||||
}
|
||||
20
frontend/src/components/shared/theme-provider.tsx
Normal file
20
frontend/src/components/shared/theme-provider.tsx
Normal 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
25
frontend/src/i18n.ts
Normal 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
59
frontend/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
11
frontend/src/lib/query-client.ts
Normal file
11
frontend/src/lib/query-client.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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
10
frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
33
frontend/src/pages/dashboard.tsx
Normal file
33
frontend/src/pages/dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
frontend/src/pages/login.tsx
Normal file
27
frontend/src/pages/login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/pages/not-found.tsx
Normal file
19
frontend/src/pages/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
frontend/src/pages/register.tsx
Normal file
27
frontend/src/pages/register.tsx
Normal 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
33
frontend/src/routes.tsx
Normal 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 />,
|
||||
},
|
||||
]);
|
||||
41
frontend/src/stores/auth-store.ts
Normal file
41
frontend/src/stores/auth-store.ts
Normal 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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
28
frontend/src/stores/ui-store.ts
Normal file
28
frontend/src/stores/ui-store.ts
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user