TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Sécurité : gestion des comptes (rôle, cours, désactivation, réinitialisation du mot3// de passe, révocation des sessions) et journal des événements d'authentification.4import { useState } from "react";5import { Check, Copy, KeyRound, LogOut, ShieldAlert, Users } from "lucide-react";6import { PageHeader } from "@/components/app-shell";7import { Badge, Button, Card, EmptyState, Modal, Skeleton, Spinner, cn } from "@/components/ui";8import { ErrorBanner, SectionTitle, fmtDate, fmtInt, postJson, useFetchJson } from "./shared";910type UserRow = {11 id: number;12 username: string;13 display_name: string;14 email: string | null;15 role: "student" | "instructor" | "admin";16 disabled: number;17 must_change_password: number;18 is_initial_admin: number;19 created_at: string;20 last_login_at: string | null;21 courses: string | null; // "IMM1003,IMM1033"22 sessions: number;23};2425type AuthEvent = {26 id: number;27 user_id: number | null;28 username: string | null;29 event: string;30 ip: string | null;31 detail: string | null;32 created_at: string;33};3435const ROLE_LABEL: Record<UserRow["role"], string> = {36 student: "Étudiant·e",37 instructor: "Professeur",38 admin: "Admin",39};4041const COURSES = ["IMM1003", "IMM1033"] as const;4243function eventTone(event: string): string {44 if (/failed|rate-limited|disabled/.test(event)) return "text-red-600 dark:text-red-400 font-medium";45 if (event === "login-success" || event === "register") return "text-emerald-700 dark:text-emerald-400";46 return "text-fg";47}4849export function AdminSecurity() {50 const { data, error, loading, reload } = useFetchJson<{ users: UserRow[]; events: AuthEvent[] }>("/api/admin/users");51 const [actionError, setActionError] = useState<string | null>(null);52 const [busyId, setBusyId] = useState<number | null>(null);5354 // Réinitialisation du mot de passe55 const [resetTarget, setResetTarget] = useState<UserRow | null>(null);56 const [tempPassword, setTempPassword] = useState<string | null>(null);57 const [copied, setCopied] = useState(false);5859 async function patch(userId: number, body: Record<string, unknown>): Promise<{ ok: boolean; tempPassword: string | null } | null> {60 setBusyId(userId);61 setActionError(null);62 try {63 const r = await postJson<{ ok: boolean; tempPassword: string | null }>("/api/admin/users", { userId, ...body }, "PATCH");64 await reload();65 return r;66 } catch (e) {67 setActionError(e instanceof Error ? e.message : "L'action a échoué.");68 return null;69 } finally {70 setBusyId(null);71 }72 }7374 function toggleCourse(u: UserRow, course: string) {75 const current = (u.courses ?? "").split(",").filter(Boolean);76 const next = current.includes(course) ? current.filter((c) => c !== course) : [...current, course];77 patch(u.id, { courses: next });78 }7980 async function resetPassword() {81 if (!resetTarget) return;82 const r = await patch(resetTarget.id, { resetPassword: true });83 if (r?.tempPassword) {84 setTempPassword(r.tempPassword);85 setCopied(false);86 }87 }8889 async function copyPassword() {90 if (!tempPassword) return;91 try {92 await navigator.clipboard.writeText(tempPassword);93 setCopied(true);94 setTimeout(() => setCopied(false), 2500);95 } catch {96 setActionError("Impossible de copier — sélectionnez le mot de passe manuellement.");97 }98 }99100 function closeResetModal() {101 setResetTarget(null);102 setTempPassword(null);103 setCopied(false);104 }105106 return (107 <div className="animate-fade-up">108 <PageHeader title="Sécurité" subtitle="Comptes, rôles, sessions et journal d'authentification" />109110 {actionError && <div className="mb-4"><ErrorBanner message={actionError} /></div>}111112 {loading ? (113 <div className="space-y-4">114 <Skeleton className="h-72" />115 <Skeleton className="h-56" />116 </div>117 ) : error || !data ? (118 <ErrorBanner119 message={120 error?.includes("403") || error?.toLowerCase().includes("accès")121 ? "Accès refusé — la gestion des comptes est réservée aux administrateurs."122 : error ?? "Données indisponibles."123 }124 onRetry={reload}125 />126 ) : (127 <>128 {/* Utilisateurs */}129 {data.users.length === 0 ? (130 <Card>131 <EmptyState icon={<Users />} title="Aucun utilisateur" description="Aucun compte n'existe encore." />132 </Card>133 ) : (134 <Card className="overflow-hidden">135 <div className="overflow-x-auto">136 <table className="w-full text-[13px]">137 <thead>138 <tr className="border-b border-app text-left text-[12px] text-muted">139 <th className="px-4 py-2 font-medium">Identifiant</th>140 <th className="px-3 py-2 font-medium">Nom</th>141 <th className="px-3 py-2 font-medium">Rôle</th>142 <th className="px-3 py-2 font-medium">Cours</th>143 <th className="px-3 py-2 text-right font-medium">Sessions</th>144 <th className="px-3 py-2 font-medium">Dernière connexion</th>145 <th className="px-4 py-2 font-medium">Actions</th>146 </tr>147 </thead>148 <tbody>149 {data.users.map((u) => (150 <tr key={u.id} className={cn("border-b border-app last:border-0", !!u.disabled && "opacity-60")}>151 <td className="px-4 py-2.5">152 <div className="flex flex-wrap items-center gap-1.5">153 <span className="font-mono text-[12.5px] text-fg">{u.username}</span>154 {!!u.is_initial_admin && <Badge tone="amber">admin initial</Badge>}155 {!!u.disabled && <Badge tone="red">désactivé</Badge>}156 </div>157 </td>158 <td className="px-3 py-2.5 text-fg">{u.display_name || "—"}</td>159 <td className="px-3 py-2.5">160 <select161 value={u.role}162 onChange={(e) => patch(u.id, { role: e.target.value })}163 disabled={busyId === u.id}164 aria-label={`Rôle de ${u.username}`}165 className="h-8 rounded-lg border border-app bg-card px-2 text-[12.5px] text-fg outline-none focus:border-brand-400 focus:ring-2 focus:ring-brand-500/25"166 >167 {(Object.keys(ROLE_LABEL) as UserRow["role"][]).map((r) => (168 <option key={r} value={r}>{ROLE_LABEL[r]}</option>169 ))}170 </select>171 </td>172 <td className="px-3 py-2.5">173 <div className="flex gap-3">174 {COURSES.map((c) => {175 const enrolled = (u.courses ?? "").split(",").includes(c);176 return (177 <label key={c} className="flex items-center gap-1.5 text-[12px] text-muted">178 <input179 type="checkbox"180 checked={enrolled}181 disabled={busyId === u.id}182 onChange={() => toggleCourse(u, c)}183 className="h-3.5 w-3.5 accent-[var(--color-brand-600)]"184 />185 {c}186 </label>187 );188 })}189 </div>190 </td>191 <td className="px-3 py-2.5 text-right tabular-nums text-fg">{fmtInt(u.sessions)}</td>192 <td className="whitespace-nowrap px-3 py-2.5 tabular-nums text-muted">{u.last_login_at ? fmtDate(u.last_login_at) : "Jamais"}</td>193 <td className="px-4 py-2.5">194 <div className="flex items-center gap-1">195 {busyId === u.id && <Spinner className="text-muted" />}196 <Button197 size="sm"198 variant="ghost"199 onClick={() => patch(u.id, { disabled: !u.disabled })}200 disabled={busyId === u.id}201 title={u.disabled ? "Réactiver le compte" : "Désactiver le compte"}202 className={u.disabled ? "" : "text-red-600 hover:bg-red-500/10 dark:text-red-400"}203 >204 <ShieldAlert size={14} />205 {u.disabled ? "Réactiver" : "Désactiver"}206 </Button>207 <Button208 size="sm"209 variant="ghost"210 onClick={() => { setTempPassword(null); setResetTarget(u); }}211 disabled={busyId === u.id}212 title="Réinitialiser le mot de passe"213 >214 <KeyRound size={14} />215 Réinit. mdp216 </Button>217 <Button218 size="sm"219 variant="ghost"220 onClick={() => patch(u.id, { revokeSessions: true })}221 disabled={busyId === u.id || u.sessions === 0}222 title="Révoquer toutes les sessions"223 >224 <LogOut size={14} />225 Révoquer226 </Button>227 </div>228 </td>229 </tr>230 ))}231 </tbody>232 </table>233 </div>234 </Card>235 )}236237 {/* Journal d'authentification */}238 <section className="mt-8">239 <SectionTitle sub="100 derniers événements — échecs en rouge">Journal des événements d'authentification</SectionTitle>240 {data.events.length === 0 ? (241 <Card className="p-5">242 <p className="text-sm text-muted">Aucun événement enregistré.</p>243 </Card>244 ) : (245 <Card className="overflow-hidden">246 <div className="max-h-[480px] overflow-auto">247 <table className="w-full text-[13px]">248 <thead className="sticky top-0 bg-card">249 <tr className="border-b border-app text-left text-[12px] text-muted">250 <th className="px-4 py-2 font-medium">Événement</th>251 <th className="px-3 py-2 font-medium">Utilisateur</th>252 <th className="px-3 py-2 font-medium">IP</th>253 <th className="px-4 py-2 font-medium">Date</th>254 </tr>255 </thead>256 <tbody>257 {data.events.map((e) => (258 <tr key={e.id} className="border-b border-app last:border-0">259 <td className={cn("px-4 py-1.5 font-mono text-[12px]", eventTone(e.event))}>{e.event}</td>260 <td className="px-3 py-1.5 font-mono text-[12px] text-fg">{e.username ?? "—"}</td>261 <td className="px-3 py-1.5 font-mono text-[12px] text-muted">{e.ip ?? "—"}</td>262 <td className="whitespace-nowrap px-4 py-1.5 tabular-nums text-muted">{fmtDate(e.created_at)}</td>263 </tr>264 ))}265 </tbody>266 </table>267 </div>268 </Card>269 )}270 </section>271 </>272 )}273274 {/* Modal de réinitialisation */}275 <Modal276 open={resetTarget !== null}277 onClose={closeResetModal}278 title={tempPassword ? "Mot de passe temporaire généré" : "Réinitialiser le mot de passe"}279 >280 {!tempPassword ? (281 <>282 <p className="text-sm text-fg">283 Réinitialiser le mot de passe de <span className="font-mono">{resetTarget?.username}</span> ?284 </p>285 <p className="mt-2 text-[13px] text-muted">286 Un mot de passe temporaire sera généré, toutes ses sessions seront fermées et la personne devra choisir287 un nouveau mot de passe à sa prochaine connexion.288 </p>289 <div className="mt-5 flex justify-end gap-2">290 <Button variant="secondary" onClick={closeResetModal}>Annuler</Button>291 <Button variant="danger" onClick={resetPassword} disabled={busyId !== null}>292 {busyId !== null && <Spinner />}293 Réinitialiser294 </Button>295 </div>296 </>297 ) : (298 <>299 <p className="text-sm text-fg">300 Mot de passe temporaire pour <span className="font-mono">{resetTarget?.username}</span> :301 </p>302 <div className="mt-3 flex items-center gap-2">303 <code className="flex-1 select-all rounded-lg border border-app bg-surface-1 px-4 py-2.5 font-mono text-[15px] font-semibold tracking-wide text-fg dark:bg-brand-950">304 {tempPassword}305 </code>306 <Button variant="secondary" onClick={copyPassword}>307 {copied ? <Check size={15} className="text-emerald-600" /> : <Copy size={15} />}308 {copied ? "Copié" : "Copier"}309 </Button>310 </div>311 <div className="mt-4 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-[13px] text-amber-800 dark:text-amber-300">312 Ce mot de passe ne sera plus jamais affiché. Transmettez-le de façon sécurisée (en personne ou par un canal313 chiffré) — jamais par courriel en clair. La personne devra le remplacer dès sa première connexion.314 </div>315 <div className="mt-5 flex justify-end">316 <Button onClick={closeResetModal}>Fermer</Button>317 </div>318 </>319 )}320 </Modal>321 </div>322 );323}324