Repository Public
1"use client";2// Auteur : Simon-Pierre Boucher — contact@spboucher.ai3// /admin/equipe — composants interactifs : changement de SON mot de passe,4// fiche d'un membre (rôles, activation, réinitialisation) et création de5// compte. Toutes les règles sont revérifiées par l'API côté serveur.6import { useState } from "react";7import { useRouter } from "next/navigation";8import { ADMIN_ROLES } from "@/lib/comms/categories";910function useApi() {11 const router = useRouter();12 const [busy, setBusy] = useState(false);13 const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);14 async function call(url: string, method: string, body: unknown): Promise<boolean> {15 setBusy(true);16 setMsg(null);17 try {18 const res = await fetch(url, {19 method,20 headers: { "Content-Type": "application/json" },21 body: JSON.stringify(body),22 });23 const data = (await res.json().catch(() => ({}))) as { error?: string };24 if (!res.ok) {25 setMsg({ ok: false, text: data.error ?? "Action refusée." });26 setBusy(false);27 return false;28 }29 router.refresh();30 setBusy(false);31 return true;32 } catch {33 setMsg({ ok: false, text: "Réseau indisponible." });34 setBusy(false);35 return false;36 }37 }38 return { busy, msg, setMsg, call };39}4041function Feedback({ msg }: { msg: { ok: boolean; text: string } | null }) {42 if (!msg) return null;43 return (44 <p45 className={`gk-mono mt-3 rounded-lg border-[1.5px] px-3 py-2 text-[11px] font-bold ${46 msg.ok47 ? "border-green bg-lime-soft text-green"48 : "border-danger bg-[var(--danger-soft)] text-danger"49 }`}50 >51 {msg.text}52 </p>53 );54}5556/* ---------- mon mot de passe ---------- */5758export function ChangeMyPassword() {59 const { busy, msg, setMsg, call } = useApi();60 const [current, setCurrent] = useState("");61 const [next, setNext] = useState("");62 const [confirm, setConfirm] = useState("");63 return (64 <form65 className="mt-4"66 onSubmit={async (e) => {67 e.preventDefault();68 if (next !== confirm) {69 setMsg({ ok: false, text: "La confirmation ne correspond pas." });70 return;71 }72 if (await call("/api/admin/password", "POST", { current, next })) {73 setMsg({ ok: true, text: "Mot de passe changé — vos autres sessions sont déconnectées." });74 setCurrent("");75 setNext("");76 setConfirm("");77 }78 }}79 >80 <label className="klabel mb-[4px] block" htmlFor="pw-cur">Mot de passe actuel</label>81 <input id="pw-cur" type="password" className="field" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} required />82 <div className="mt-3 grid gap-3 sm:grid-cols-2">83 <div>84 <label className="klabel mb-[4px] block" htmlFor="pw-next">Nouveau (8 car. min.)</label>85 <input id="pw-next" type="password" className="field" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} required minLength={8} />86 </div>87 <div>88 <label className="klabel mb-[4px] block" htmlFor="pw-conf">Confirmation</label>89 <input id="pw-conf" type="password" className="field" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required minLength={8} />90 </div>91 </div>92 <button type="submit" className="btn btn-primary mt-4 !min-h-[40px] !px-5 !text-[13px]" disabled={busy}>93 Changer mon mot de passe94 </button>95 <Feedback msg={msg} />96 </form>97 );98}99100/* ---------- fiche membre (super admin) ---------- */101102export function TeamMemberRow({103 admin,104 self,105}: {106 admin: {107 id: number;108 username: string;109 displayName: string;110 email: string | null;111 roles: string[];112 active: boolean;113 lastLogin: string | null;114 };115 self: boolean;116}) {117 const { busy, msg, setMsg, call } = useApi();118 const [roles, setRoles] = useState<string[]>(admin.roles);119 const [reset, setReset] = useState("");120 const [open, setOpen] = useState(false);121122 function toggleRole(r: string) {123 setRoles((cur) => (cur.includes(r) ? cur.filter((x) => x !== r) : [...cur, r]));124 }125126 return (127 <div className={`gk-card p-5 !shadow-none ${admin.active ? "" : "opacity-60"}`}>128 <div className="flex flex-wrap items-center gap-3">129 <div className="min-w-0">130 <p className="gk-display text-[16px] font-bold">131 {admin.displayName}132 {self ? <span className="gk-mono ml-2 text-[10px] font-bold text-green uppercase">(vous)</span> : null}133 </p>134 <p className="gk-mono text-[10.5px] text-ink-3">135 @{admin.username}136 {admin.email ? ` · ${admin.email}` : ""}137 {admin.lastLogin ? ` · vu le ${admin.lastLogin.slice(0, 16).replace("T", " ")}` : " · jamais connecté"}138 </p>139 </div>140 <div className="ml-auto flex flex-wrap gap-1">141 {admin.roles.map((r) => (142 <span key={r} className="adm-chip !text-[9px]">{ADMIN_ROLES[r] ?? r}</span>143 ))}144 {!admin.active ? <span className="adm-chip adm-chip--urgente !text-[9px]">Désactivé</span> : null}145 </div>146 <button147 className="gk-mono cursor-pointer text-[10.5px] font-bold tracking-[0.08em] text-green uppercase underline-offset-4 hover:underline"148 onClick={() => setOpen((o) => !o)}149 >150 {open ? "Fermer" : "Gérer"}151 </button>152 </div>153154 {open ? (155 <div className="rule-hair mt-4 pt-4">156 <p className="klabel">Rôles</p>157 <div className="mt-2 flex flex-wrap gap-x-5 gap-y-2">158 {Object.entries(ADMIN_ROLES).map(([k, v]) => (159 <label key={k} className="flex cursor-pointer items-center gap-2 text-[12.5px]">160 <input161 type="checkbox"162 checked={roles.includes(k)}163 onChange={() => toggleRole(k)}164 className="h-4 w-4 accent-[var(--green)]"165 />166 {v}167 </label>168 ))}169 </div>170 <div className="mt-4 flex flex-wrap items-end gap-3">171 <button172 className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]"173 disabled={busy}174 onClick={() => call(`/api/admin/team/${admin.id}`, "PATCH", { roles })}175 >176 Enregistrer les rôles177 </button>178 <button179 className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]"180 disabled={busy || self}181 onClick={() => call(`/api/admin/team/${admin.id}`, "PATCH", { active: !admin.active })}182 >183 {admin.active ? "Désactiver" : "Réactiver"}184 </button>185 <div className="flex items-end gap-2">186 <div>187 <label className="klabel mb-[4px] block" htmlFor={`rs-${admin.id}`}>188 Nouveau mot de passe189 </label>190 <input191 id={`rs-${admin.id}`}192 type="password"193 className="field !py-[7px] !text-[13px]"194 value={reset}195 onChange={(e) => setReset(e.target.value)}196 minLength={8}197 autoComplete="new-password"198 />199 </div>200 <button201 className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]"202 disabled={busy || reset.length < 8}203 onClick={async () => {204 if (await call(`/api/admin/team/${admin.id}`, "PATCH", { password: reset })) {205 setReset("");206 setMsg({ ok: true, text: "Mot de passe réinitialisé (sessions du compte invalidées)." });207 }208 }}209 >210 Réinitialiser211 </button>212 </div>213 </div>214 <Feedback msg={msg} />215 </div>216 ) : null}217 </div>218 );219}220221/* ---------- création de compte (super admin) ---------- */222223export function CreateAdminForm() {224 const { busy, msg, setMsg, call } = useApi();225 const [username, setUsername] = useState("");226 const [displayName, setDisplayName] = useState("");227 const [email, setEmail] = useState("");228 const [password, setPassword] = useState("");229 const [roles, setRoles] = useState<string[]>(["support"]);230231 return (232 <form233 onSubmit={async (e) => {234 e.preventDefault();235 if (236 await call("/api/admin/team", "POST", {237 username,238 display_name: displayName,239 email: email || undefined,240 password,241 roles,242 })243 ) {244 setMsg({ ok: true, text: `Compte « ${username} » créé.` });245 setUsername("");246 setDisplayName("");247 setEmail("");248 setPassword("");249 setRoles(["support"]);250 }251 }}252 >253 <div className="grid gap-3 sm:grid-cols-2">254 <div>255 <label className="klabel mb-[4px] block" htmlFor="na-user">Identifiant</label>256 <input id="na-user" className="field" value={username} onChange={(e) => setUsername(e.target.value)} required pattern="[a-z0-9._-]{3,40}" autoCapitalize="none" />257 </div>258 <div>259 <label className="klabel mb-[4px] block" htmlFor="na-name">Nom d'affichage</label>260 <input id="na-name" className="field" value={displayName} onChange={(e) => setDisplayName(e.target.value)} required />261 </div>262 <div>263 <label className="klabel mb-[4px] block" htmlFor="na-email">Courriel (facultatif)</label>264 <input id="na-email" type="email" className="field" value={email} onChange={(e) => setEmail(e.target.value)} />265 </div>266 <div>267 <label className="klabel mb-[4px] block" htmlFor="na-pass">Mot de passe initial (8 car. min.)</label>268 <input id="na-pass" type="password" className="field" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8} autoComplete="new-password" />269 </div>270 </div>271 <p className="klabel mt-4">Rôles</p>272 <div className="mt-2 flex flex-wrap gap-x-5 gap-y-2">273 {Object.entries(ADMIN_ROLES).map(([k, v]) => (274 <label key={k} className="flex cursor-pointer items-center gap-2 text-[12.5px]">275 <input276 type="checkbox"277 checked={roles.includes(k)}278 onChange={() =>279 setRoles((cur) => (cur.includes(k) ? cur.filter((x) => x !== k) : [...cur, k]))280 }281 className="h-4 w-4 accent-[var(--green)]"282 />283 {v}284 </label>285 ))}286 </div>287 <button type="submit" className="btn btn-primary mt-5 !min-h-[40px] !px-5 !text-[13px]" disabled={busy}>288 Créer le compte289 </button>290 <Feedback msg={msg} />291 </form>292 );293}294