"use client";
import { useEffect, useState, type FormEvent, type ReactNode } from "react";
import useSWR from "swr";
import { Monitor, Moon, Sun, LogOut, ShieldCheck, Smartphone, Download } from "lucide-react";
import { api, fetcher, relTime } from "@/lib/client/fetcher";
import { usePrefs, setPrefs, type Prefs } from "@/lib/client/prefs";
import { useModels } from "@/lib/client/use-models";
import { Badge, Button, Input, Toggle, Spinner, cn, toast } from "../ui";
export function Section({ id, title, hint, children }: { id: string; title: string; hint?: string; children: ReactNode }) {
return {title} {hint && {hint}
}{children}
;
}
export function Row({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return
{label} {hint && {hint} }{children}
;
}
// ---------------------------------------------------------------- Apparence
export function AppearanceSection() {
const prefs = usePrefs();
const themes: { v: Prefs["theme"]; l: string; I: typeof Sun }[] = [{ v: "dark", l: "Sombre", I: Moon }, { v: "light", l: "Clair", I: Sun }, { v: "system", l: "Système", I: Monitor }];
return (
{themes.map(t => setPrefs({ theme: t.v })} className={cn("inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[13px]", prefs.theme === t.v ? "bg-surface-2 text-fg" : "text-muted hover:text-fg")}> {t.l} )}
setPrefs({ fontSize: e.target.value as Prefs["fontSize"] })} className="input !w-auto !py-1 text-[13px]">Petite Normale Grande
setPrefs({ sendWithEnter: v })} />
setPrefs({ showReasoning: v })} />
);
}
// ---------------------------------------------------------------- Modèles & routage
export function ModelsSection({ routing, onSave }: { routing: { defaultMode?: string }; onSave: (key: string, value: unknown) => Promise }) {
const prefs = usePrefs(); const { chat, defaultModel, mutate } = useModels();
const [saving, setSaving] = useState(false);
async function setDefault(id: string) { setSaving(true); try { await api("/api/models", { method: "PATCH", json: { id, default: true } }); await mutate(); toast("Modèle par défaut enregistré", "ok"); } finally { setSaving(false); } }
const modes = [{ v: "manual", l: "Manuel" }, { v: "auto", l: "Automatique" }, { v: "fast", l: "Rapide" }, { v: "quality", l: "Qualité" }, { v: "economy", l: "Économie" }];
return (
{saving && } e.target.value && setDefault(e.target.value)} className="input !w-auto max-w-[60vw] !py-1 text-[13px]">— aucun — {chat.map(m => {m.displayName} ({m.provider}) )}
{ const v = e.target.value as Prefs["routingMode"]; setPrefs({ routingMode: v }); await onSave("routing", { ...routing, defaultMode: v }); }} className="input !w-auto !py-1 text-[13px]">{modes.map(m => {m.l} )}
);
}
// ---------------------------------------------------------------- Dépenses
export interface Spending { dailyLimitUsd: number; monthlyLimitUsd: number; alertAtPercent: number }
export function SpendingSection({ value, onSave }: { value: Spending; onSave: (key: string, value: unknown) => Promise }) {
const [v, setV] = useState(value); const [busy, setBusy] = useState(false);
useEffect(() => setV(value), [value]);
const num = (k: keyof Spending) => (e: React.ChangeEvent) => setV(x => ({ ...x, [k]: Number(e.target.value) }));
return (
);
}
// ---------------------------------------------------------------- Outils & intégrations
export interface ToolInfo { name: string; description: string; permission: string; category: string }
export function ToolsSection({ tools }: { tools: ToolInfo[] }) {
return (
{tools.map(t =>
{t.name}{t.description} {t.permission === "auto" ? "auto" : "approbation"}
)}
L'outil Python s'exécute dans un bac à sable (sandbox-exec, sans réseau, 20 s, répertoire temporaire isolé). Aucun outil n'a d'accès shell au cluster. Les permissions par projet se règlent dans chaque projet.
);
}
export function IntegrationsSection({ integrations }: { integrations: { openai: boolean; anthropic: boolean; tavily: boolean } }) {
const items = [{ k: "openai", l: "OpenAI", d: "Modèles GPT / o-series, images, voix, embeddings" }, { k: "anthropic", l: "Anthropic", d: "Modèles Claude" }, { k: "tavily", l: "Tavily", d: "Recherche web et extraction de pages" }] as const;
return (
{items.map(i => {integrations[i.k] ? "configurée" : "absente"}
)}
à venir
);
}
// ---------------------------------------------------------------- Sécurité
interface SessionInfo { id: string; createdAt: string; expiresAt: string; lastSeen: string; userAgent: string | null; ip: string | null; current: boolean }
export function SecuritySection({ owner }: { owner: { username: string; createdAt: string; totp: boolean } | null }) {
const { data, mutate } = useSWR<{ sessions: SessionInfo[] }>("/api/auth/sessions", fetcher);
const [cur, setCur] = useState(""); const [next, setNext] = useState(""); const [confirm2, setConfirm2] = useState(""); const [busy, setBusy] = useState(false);
async function changePw(e: FormEvent) {
e.preventDefault(); if (next !== confirm2) { toast("Les mots de passe ne correspondent pas", "error"); return; }
setBusy(true);
try { const r = await api<{ note?: string }>("/api/settings", { method: "PATCH", json: { password: { current: cur, next } } }); toast(r.note ?? "Mot de passe changé", "ok"); setCur(""); setNext(""); setConfirm2(""); void mutate(); }
catch (err) { toast((err as Error).message, "error"); } finally { setBusy(false); }
}
async function revoke(id?: string) { await api("/api/auth/sessions", { method: "DELETE", json: id ? { id } : { all: true } }); toast(id ? "Session révoquée" : "Autres sessions révoquées", "ok"); void mutate(); }
async function logout() { await api("/api/auth/logout", { method: "POST" }); navigator.serviceWorker?.controller?.postMessage("logout"); location.href = "/login"; }
const ua = (s: string | null) => { if (!s) return "appareil inconnu"; const m = /(iPhone|iPad|Android|Macintosh|Windows|Linux)/.exec(s)?.[1] ?? "navigateur"; const b = /(Safari|Chrome|Firefox|Edg)/.exec(s)?.[1] ?? ""; return `${m}${b ? ` · ${b}` : ""}`; };
return (
{owner?.username ?? "—"}
{owner?.totp ? "activée" : "désactivée"}
Sessions actives {(data?.sessions.length ?? 0) > 1 && revoke()}>Révoquer les autres }
{!data &&
}
{data?.sessions.map(s => (
{ua(s.userAgent)}{s.current && cette session } {s.ip ?? "IP inconnue"} · vue {relTime(s.lastSeen)} · expire {new Date(s.expiresAt).toLocaleDateString("fr-CA")}
{!s.current && revoke(s.id)}>Révoquer }
))}
Se déconnecter
);
}
// ---------------------------------------------------------------- PWA
interface BeforeInstallPromptEvent extends Event { prompt(): Promise; userChoice: Promise<{ outcome: "accepted" | "dismissed" }> }
export function InstallHint() {
const [evt, setEvt] = useState(null);
useEffect(() => { const h = (e: Event) => { e.preventDefault(); setEvt(e as BeforeInstallPromptEvent); }; window.addEventListener("beforeinstallprompt", h); return () => window.removeEventListener("beforeinstallprompt", h); }, []);
if (!evt) return null;
return Installe Chat SPBoucher AI comme application (écran d'accueil, plein écran, démarrage rapide). { await evt.prompt(); const c = await evt.userChoice; if (c.outcome === "accepted") setEvt(null); }}>Installer
;
}