Phase 6: PDF & Polish — PDF generation, admin users/settings, AI tool

Backend:
- Setting + GeneratedPdf models, Alembic migration with default settings seed
- PDF generation service (WeasyPrint + Jinja2 with autoescape)
- Health report HTML template with memory entries + document excerpts
- Admin user management: list, create, update (role/max_chats/is_active)
- Admin settings: self_registration_enabled, default_max_chats
- Self-registration check wired into auth register endpoint
- default_max_chats applied to new user registrations
- AI tool: generate_pdf creates health compilation PDFs
- PDF compile/list/download API endpoints
- WeasyPrint system deps added to Dockerfile

Frontend:
- PDF reports page with generate + download
- Admin users page with create/edit/activate/deactivate
- Admin settings page with self-registration toggle + max chats
- Extended sidebar with PDF reports + admin users/settings links
- English + Russian translations for all new UI

Review fixes applied:
- Jinja2 autoescape enabled (XSS prevention in PDFs)
- db.refresh after flush (created_at populated correctly)
- storage_path removed from API response (no internal path leak)
- Role field uses Literal["user", "admin"] validation
- React hooks called before conditional returns (rules of hooks)
- default_max_chats setting now applied during registration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 14:37:43 +03:00
parent 04e3ae8319
commit fed6a3df1b
33 changed files with 1219 additions and 10 deletions

View File

@@ -0,0 +1,81 @@
import { useState, useEffect } from "react";
import { Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/stores/auth-store";
import { getSettings, updateSetting } from "@/api/admin";
import { Save } from "lucide-react";
export function AdminSettingsPage() {
const { t } = useTranslation();
const user = useAuthStore((s) => s.user);
const queryClient = useQueryClient();
const [selfReg, setSelfReg] = useState(true);
const [defaultMaxChats, setDefaultMaxChats] = useState(10);
const { data: settings } = useQuery({
queryKey: ["admin-settings"],
queryFn: getSettings,
});
useEffect(() => {
if (settings) {
const reg = settings.find((s) => s.key === "self_registration_enabled");
const chats = settings.find((s) => s.key === "default_max_chats");
if (reg) setSelfReg(Boolean(reg.value));
if (chats) setDefaultMaxChats(Number(chats.value));
}
}, [settings]);
const mutation = useMutation({
mutationFn: async () => {
await updateSetting("self_registration_enabled", selfReg);
await updateSetting("default_max_chats", defaultMaxChats);
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["admin-settings"] }),
});
if (user?.role !== "admin") return <Navigate to="/" replace />;
return (
<div className="max-w-lg space-y-6">
<h1 className="text-2xl font-semibold">{t("admin_settings.title")}</h1>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border bg-card p-4">
<div>
<p className="font-medium">{t("admin_settings.self_registration")}</p>
<p className="text-sm text-muted-foreground">{t("admin_settings.self_registration_desc")}</p>
</div>
<button
onClick={() => setSelfReg(!selfReg)}
className={`relative h-6 w-11 rounded-full transition-colors ${selfReg ? "bg-primary" : "bg-muted"}`}
>
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${selfReg ? "translate-x-5" : "translate-x-0.5"}`} />
</button>
</div>
<div className="rounded-lg border bg-card p-4 space-y-2">
<p className="font-medium">{t("admin_settings.default_max_chats")}</p>
<p className="text-sm text-muted-foreground">{t("admin_settings.default_max_chats_desc")}</p>
<input
type="number"
value={defaultMaxChats}
onChange={(e) => setDefaultMaxChats(Number(e.target.value))}
min={1}
max={100}
className="flex h-10 w-24 rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
</div>
<button
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
className="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Save className="h-4 w-4" /> {mutation.isPending ? t("common.loading") : t("common.save")}
</button>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { useState } from "react";
import { Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/stores/auth-store";
import { listUsers, createUser, updateUser, type AdminUser } from "@/api/admin";
import { Plus, Pencil, UserCheck, UserX } from "lucide-react";
import { cn } from "@/lib/utils";
export function AdminUsersPage() {
const { t } = useTranslation();
const user = useAuthStore((s) => s.user);
const queryClient = useQueryClient();
const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState<AdminUser | null>(null);
// Form state
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [fullName, setFullName] = useState("");
const [role, setRole] = useState("user");
const [maxChats, setMaxChats] = useState(10);
const { data } = useQuery({
queryKey: ["admin-users"],
queryFn: () => listUsers(200),
});
const createMut = useMutation({
mutationFn: () => createUser({ email, username, password, full_name: fullName || undefined, role, max_chats: maxChats }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["admin-users"] }); resetForm(); },
});
const updateMut = useMutation({
mutationFn: (data: { id: string; role?: string; is_active?: boolean; max_chats?: number }) => {
const { id, ...updates } = data;
return updateUser(id, updates);
},
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["admin-users"] }); setEditing(null); },
});
function resetForm() {
setCreating(false); setEditing(null);
setEmail(""); setUsername(""); setPassword(""); setFullName(""); setRole("user"); setMaxChats(10);
}
function startEdit(u: AdminUser) {
setEditing(u); setRole(u.role); setMaxChats(u.max_chats); setFullName(u.full_name || "");
}
if (user?.role !== "admin") return <Navigate to="/" replace />;
if (creating) {
return (
<div className="max-w-lg space-y-4">
<h1 className="text-2xl font-semibold">{t("admin_users.create")}</h1>
<form onSubmit={(e) => { e.preventDefault(); createMut.mutate(); }} className="space-y-3">
<input value={email} onChange={(e) => setEmail(e.target.value)} required type="email" placeholder={t("auth.email")} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
<input value={username} onChange={(e) => setUsername(e.target.value)} required placeholder={t("auth.username")} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
<input value={password} onChange={(e) => setPassword(e.target.value)} required type="password" placeholder={t("auth.password")} minLength={8} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
<input value={fullName} onChange={(e) => setFullName(e.target.value)} placeholder={t("auth.fullName")} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
<select value={role} onChange={(e) => setRole(e.target.value)} className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<div className="flex gap-2">
<button type="button" onClick={resetForm} className="h-9 rounded-md border px-4 text-sm">{t("common.cancel")}</button>
<button type="submit" disabled={createMut.isPending} className="h-9 rounded-md bg-primary px-4 text-sm text-primary-foreground">{t("common.create")}</button>
</div>
</form>
</div>
);
}
if (editing) {
return (
<div className="max-w-lg space-y-4">
<h1 className="text-2xl font-semibold">{t("admin_users.edit")}: {editing.username}</h1>
<form onSubmit={(e) => { e.preventDefault(); updateMut.mutate({ id: editing.id, role, max_chats: maxChats }); }} className="space-y-3">
<select value={role} onChange={(e) => setRole(e.target.value)} className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<div className="space-y-1">
<label className="text-sm font-medium">{t("admin_users.max_chats")}</label>
<input type="number" value={maxChats} onChange={(e) => setMaxChats(Number(e.target.value))} min={1} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</div>
<div className="flex gap-2">
<button type="button" onClick={resetForm} className="h-9 rounded-md border px-4 text-sm">{t("common.cancel")}</button>
<button type="submit" disabled={updateMut.isPending} className="h-9 rounded-md bg-primary px-4 text-sm text-primary-foreground">{t("common.save")}</button>
</div>
</form>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">{t("admin_users.title")}</h1>
<button onClick={() => setCreating(true)} className="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground">
<Plus className="h-4 w-4" /> {t("common.create")}
</button>
</div>
<div className="space-y-2">
{(data?.users || []).map((u) => (
<div key={u.id} className="flex items-center gap-3 rounded-lg border bg-card p-4">
<div className={cn("h-2 w-2 rounded-full", u.is_active ? "bg-green-500" : "bg-red-500")} />
<div className="flex-1 min-w-0">
<p className="font-medium">{u.username} <span className="text-xs text-muted-foreground">({u.email})</span></p>
<p className="text-xs text-muted-foreground">{u.role} &middot; {t("admin_users.max_chats")}: {u.max_chats}</p>
</div>
<div className="flex gap-1">
<button onClick={() => startEdit(u)} className="rounded p-2 hover:bg-accent"><Pencil className="h-4 w-4" /></button>
<button
onClick={() => updateMut.mutate({ id: u.id, is_active: !u.is_active })}
className="rounded p-2 hover:bg-accent"
title={u.is_active ? t("admin_users.deactivate") : t("admin_users.activate")}
>
{u.is_active ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />}
</button>
</div>
</div>
))}
</div>
</div>
);
}