SPB Git

Repository Public MIT

account.tsx 14.8 KB 218 lines tsx

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
Files main
Menu

Theme