Repository Public MIT
1import { useState, type FormEvent } from "react";2import { useLocation } from "wouter";3import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";4import { formatDistanceToNow } from "date-fns";5import { fr } from "date-fns/locale";6import {7 Check, Laptop, Loader2, Lock, LogOut, Mail, MailCheck, MonitorSmartphone, ShieldAlert, ShieldCheck, Trash2, User, UserCircle2, Activity,8} from "lucide-react";9import { AppShell } from "@/components/layout/app-shell";10import { PageContainer, PageHeader } from "@/components/layout/page-header";11import { Button } from "@/components/ui/button";12import { Input } from "@/components/ui/input";13import { Label } from "@/components/ui/label";14import { CodeInput, ResendButton } from "@/components/auth/code-input";15import { useToast } from "@/hooks/use-toast";16import { authApi, EVENT_LABELS, type AccountData } from "@/lib/auth-api";17import { fmtDateTime } from "@/lib/format";18import { cn } from "@/lib/utils";1920function Section({ icon: Icon, title, desc, children, tone }: { icon: typeof User; title: string; desc?: string; children: React.ReactNode; tone?: string }) {21 return (22 <section className="surface overflow-hidden">23 <header className="flex items-start gap-3 border-b border-border px-5 py-4">24 <span className={cn("grid h-9 w-9 shrink-0 place-items-center rounded-xl", tone ?? "bg-brand-soft text-primary ring-1 ring-primary/15")}><Icon className="h-4 w-4" /></span>25 <div>26 <h2 className="text-[15px] font-semibold leading-tight">{title}</h2>27 {desc && <p className="mt-0.5 text-[12.5px] text-muted-foreground">{desc}</p>}28 </div>29 </header>30 <div className="p-5">{children}</div>31 </section>32 );33}3435export default function Account() {36 const [, setLocation] = useLocation();37 const qc = useQueryClient();38 const { toast } = useToast();39 const { data, isLoading, error } = useQuery<AccountData>({ queryKey: ["/api/auth/account"], queryFn: () => authApi.account(), retry: false });4041 const refresh = () => { qc.invalidateQueries({ queryKey: ["/api/auth/account"] }); qc.invalidateQueries({ queryKey: ["/api/auth/me"] }); };4243 /* profil */44 const [name, setName] = useState<string | null>(null);45 const profile = useMutation({46 mutationFn: (n: string) => authApi.updateProfile({ name: n }),47 onSuccess: () => { toast({ title: "Profil mis à jour" }); setName(null); refresh(); },48 onError: (e: Error) => toast({ title: "Erreur", description: e.message, variant: "destructive" }),49 });5051 /* mot de passe */52 const [cur, setCur] = useState(""); const [nw, setNw] = useState("");53 const changePw = useMutation({54 mutationFn: () => authApi.changePassword({ currentPassword: cur, newPassword: nw }),55 onSuccess: () => { toast({ title: "Mot de passe modifié", description: "Les autres appareils de confiance ont été déconnectés." }); setCur(""); setNw(""); refresh(); },56 onError: (e: Error) => toast({ title: "Erreur", description: e.message, variant: "destructive" }),57 });5859 /* lier un courriel (compte historique) */60 const [linkEmail, setLinkEmail] = useState(""); const [linkPw, setLinkPw] = useState("");61 const [linkStep, setLinkStep] = useState<{ email: string; masked: string } | null>(null);62 const [linkCode, setLinkCode] = useState("");63 const link = useMutation({64 mutationFn: () => authApi.linkEmail({ email: linkEmail.trim(), password: linkPw }),65 onSuccess: (r) => { if (r.status === "verification_required") setLinkStep({ email: r.email, masked: r.maskedEmail }); },66 onError: (e: Error) => toast({ title: "Erreur", description: e.message, variant: "destructive" }),67 });68 const verifyLink = useMutation({69 mutationFn: (code: string) => authApi.verifyEmail({ email: linkStep!.email, code, trustDevice: true }),70 onSuccess: () => { toast({ title: "Courriel confirmé", description: "Votre compte est maintenant protégé par courriel et mot de passe." }); setLinkStep(null); setLinkCode(""); refresh(); },71 onError: (e: Error) => toast({ title: "Code refusé", description: e.message, variant: "destructive" }),72 });7374 /* appareils */75 const revoke = useMutation({76 mutationFn: (id: string) => authApi.revokeDevice(id),77 onSuccess: () => { toast({ title: "Appareil révoqué" }); refresh(); },78 });79 const revokeAll = useMutation({80 mutationFn: () => authApi.revokeAllDevices(),81 onSuccess: () => { toast({ title: "Tous les appareils révoqués", description: "Un code de sécurité sera redemandé partout." }); refresh(); },82 });83 const logout = async () => { await authApi.logout(); refresh(); qc.invalidateQueries({ queryKey: ["/api/sessions"] }); setLocation("/"); };8485 if (!isLoading && (error || !data)) {86 return (87 <AppShell testId="page-account">88 <PageContainer size="md">89 <div className="surface mx-auto max-w-md p-10 text-center">90 <Lock className="mx-auto h-8 w-8 text-muted-foreground" />91 <h1 className="mt-4 font-display text-xl font-bold">Connexion requise</h1>92 <p className="mt-1 text-sm text-muted-foreground">Connectez-vous pour gérer votre compte.</p>93 <Button className="mt-4 rounded-xl bg-brand border-0 text-white" onClick={() => setLocation("/auth")}>Se connecter</Button>94 </div>95 </PageContainer>96 </AppShell>97 );98 }99100 const u = data?.user;101102 return (103 <AppShell testId="page-account">104 <PageContainer size="md">105 <PageHeader106 icon={UserCircle2}107 eyebrow="Mon compte"108 title={u ? u.displayName : "Compte"}109 description={u?.email ? <span className="inline-flex items-center gap-1.5"><Mail className="h-3.5 w-3.5" /> {u.email} {u.emailVerified && <span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-[11px] font-medium text-success"><MailCheck className="h-3 w-3" /> vérifié</span>}</span> : "Compte historique sans courriel"}110 actions={<Button variant="outline" className="rounded-xl" onClick={logout}><LogOut className="h-4 w-4" /> Déconnexion</Button>}111 />112113 {isLoading || !u ? (114 <div className="mt-8 space-y-4">{[0, 1, 2].map((i) => <div key={i} className="skeleton h-40" />)}</div>115 ) : (116 <div className="mt-8 grid gap-5 lg:grid-cols-[1fr_1fr]">117 {/* Profil */}118 <Section icon={User} title="Profil" desc="Le nom affiché dans la barre latérale et les courriels.">119 <form onSubmit={(e: FormEvent) => { e.preventDefault(); profile.mutate((name ?? u.displayName).trim()); }} className="flex flex-col gap-3 sm:flex-row sm:items-end">120 <div className="flex-1 space-y-1.5">121 <Label htmlFor="acc-name" className="text-[13px]">Nom</Label>122 <Input id="acc-name" value={name ?? u.displayName} onChange={(e) => setName(e.target.value)} minLength={2} maxLength={60} className="h-10 rounded-xl" />123 </div>124 <Button type="submit" disabled={profile.isPending || (name ?? u.displayName).trim() === u.displayName} className="h-10 rounded-xl bg-brand border-0 text-white">125 {profile.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />} Enregistrer126 </Button>127 </form>128 <dl className="mt-4 grid grid-cols-2 gap-3 text-[12.5px]">129 <div className="rounded-xl bg-muted/50 px-3 py-2"><dt className="text-muted-foreground">Membre depuis</dt><dd className="font-medium">{fmtDateTime(u.createdAt)}</dd></div>130 <div className="rounded-xl bg-muted/50 px-3 py-2"><dt className="text-muted-foreground">Dernière connexion</dt><dd className="font-medium">{u.lastLoginAt ? fmtDateTime(u.lastLoginAt) : "—"}</dd></div>131 </dl>132 </Section>133134 {/* Sécurité */}135 {u.legacy ? (136 <Section icon={ShieldAlert} title="Sécuriser ce compte" desc="Ce compte utilise encore un jeton d'accès. Ajoutez un courriel et un mot de passe." tone="bg-warning/15 text-warning-foreground dark:text-warning ring-1 ring-warning/30">137 {!linkStep ? (138 <form onSubmit={(e: FormEvent) => { e.preventDefault(); link.mutate(); }} className="space-y-3">139 <div className="space-y-1.5"><Label htmlFor="link-email" className="text-[13px]">Courriel</Label><Input id="link-email" type="email" required value={linkEmail} onChange={(e) => setLinkEmail(e.target.value)} placeholder="vous@exemple.com" className="h-10 rounded-xl" /></div>140 <div className="space-y-1.5"><Label htmlFor="link-pw" className="text-[13px]">Mot de passe</Label><Input id="link-pw" type="password" required minLength={8} value={linkPw} onChange={(e) => setLinkPw(e.target.value)} placeholder="8 caractères minimum, lettres et chiffres" autoComplete="new-password" className="h-10 rounded-xl" /></div>141 <Button type="submit" disabled={link.isPending} className="h-10 w-full rounded-xl bg-brand border-0 text-white">{link.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <MailCheck className="h-4 w-4" />} Envoyer le code de confirmation</Button>142 </form>143 ) : (144 <div className="space-y-4">145 <p className="text-[13px] text-muted-foreground">Code envoyé à <strong className="text-foreground">{linkStep.masked}</strong>.</p>146 <CodeInput value={linkCode} onChange={setLinkCode} onComplete={(c) => verifyLink.mutate(c)} disabled={verifyLink.isPending} />147 <div className="flex items-center justify-between">148 <button type="button" onClick={() => setLinkStep(null)} className="text-[12.5px] text-muted-foreground hover:text-foreground">Changer de courriel</button>149 <ResendButton onResend={async () => { await authApi.resend({ email: linkStep.email, purpose: "verify_email" }); }} />150 </div>151 </div>152 )}153 </Section>154 ) : (155 <Section icon={Lock} title="Mot de passe" desc="Après un changement, tous les appareils de confiance sont déconnectés sauf celui-ci.">156 <form onSubmit={(e: FormEvent) => { e.preventDefault(); changePw.mutate(); }} className="space-y-3">157 <div className="space-y-1.5"><Label htmlFor="cur-pw" className="text-[13px]">Mot de passe actuel</Label><Input id="cur-pw" type="password" required value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" className="h-10 rounded-xl" /></div>158 <div className="space-y-1.5"><Label htmlFor="new-pw" className="text-[13px]">Nouveau mot de passe</Label><Input id="new-pw" type="password" required minLength={8} value={nw} onChange={(e) => setNw(e.target.value)} autoComplete="new-password" placeholder="8 caractères minimum, lettres et chiffres" className="h-10 rounded-xl" /></div>159 <Button type="submit" disabled={changePw.isPending || !cur || nw.length < 8} className="h-10 w-full rounded-xl bg-brand border-0 text-white">{changePw.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />} Changer le mot de passe</Button>160 </form>161 </Section>162 )}163164 {/* Appareils */}165 <Section icon={MonitorSmartphone} title="Appareils de confiance" desc="Sur ces appareils, le code de sécurité n'est pas redemandé pendant 30 jours.">166 {data!.devices.length === 0 ? (167 <p className="text-sm text-muted-foreground">Aucun appareil de confiance. Un code sera demandé à chaque connexion.</p>168 ) : (169 <ul className="divide-y divide-border">170 {data!.devices.map((d) => (171 <li key={d.id} className="flex items-center gap-3 py-2.5">172 <span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-muted text-muted-foreground"><Laptop className="h-4 w-4" /></span>173 <span className="min-w-0 flex-1">174 <span className="flex items-center gap-2 text-[13.5px] font-medium">{d.label}{d.current && <span className="rounded-full bg-primary/10 px-1.5 py-px text-[10px] font-semibold text-primary">cet appareil</span>}</span>175 <span className="block text-[11.5px] text-muted-foreground">176 {d.ip ? `IP ${d.ip} · ` : ""}utilisé {formatDistanceToNow(new Date(d.lastUsedAt), { addSuffix: true, locale: fr })} · expire {formatDistanceToNow(new Date(d.expiresAt), { addSuffix: true, locale: fr })}177 </span>178 </span>179 <Button variant="ghost" size="icon" className="h-8 w-8 rounded-lg text-muted-foreground hover:text-destructive" onClick={() => revoke.mutate(d.id)} aria-label="Révoquer"><Trash2 className="h-4 w-4" /></Button>180 </li>181 ))}182 </ul>183 )}184 {data!.devices.length > 0 && (185 <Button variant="outline" size="sm" className="mt-3 rounded-xl" onClick={() => revokeAll.mutate()} disabled={revokeAll.isPending}>186 <ShieldAlert className="h-4 w-4" /> Révoquer tous les appareils187 </Button>188 )}189 </Section>190191 {/* Activité */}192 <Section icon={Activity} title="Activité récente" desc="Les 25 derniers événements de sécurité de votre compte.">193 {data!.events.length === 0 ? (194 <p className="text-sm text-muted-foreground">Aucune activité enregistrée.</p>195 ) : (196 <ol className="max-h-80 space-y-1 overflow-y-auto pr-1">197 {data!.events.map((e) => {198 const bad = /failed|revoked|reset/.test(e.type);199 return (200 <li key={e.id} className="flex items-start gap-2.5 rounded-lg px-2 py-1.5 hover:bg-accent/40">201 <span className={cn("mt-1.5 h-2 w-2 shrink-0 rounded-full", bad ? "bg-warning" : "bg-success")} />202 <span className="min-w-0 flex-1">203 <span className="block text-[13px] leading-snug">{EVENT_LABELS[e.type] ?? e.type}{e.meta?.via ? <span className="text-muted-foreground"> · {String(e.meta.via) === "trusted_device" ? "appareil de confiance" : String(e.meta.via) === "mfa" ? "code de sécurité" : String(e.meta.via) === "token" ? "jeton" : String(e.meta.via)}</span> : null}</span>204 <span className="block text-[11.5px] text-muted-foreground">{fmtDateTime(e.createdAt)}{e.ip ? ` · ${e.ip}` : ""}{e.device && e.device !== "Inconnu" ? ` · ${e.device}` : ""}</span>205 </span>206 </li>207 );208 })}209 </ol>210 )}211 </Section>212 </div>213 )}214 </PageContainer>215 </AppShell>216 );217}218