SPB Git

Repository Public

TeamClient.tsx 10.8 KB 294 lines tsx

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&apos;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
Files main
Menu

Theme