SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
14.4 KB · 324 lines tsx
Raw Blame History
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>&nbsp;?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>&nbsp;: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