@imenam/mcp-messenger 1.0.32 → 1.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,8 +8,10 @@
8
8
  // écrites en dur dans ce fichier. Le corps des messages passe par `renderMarkdown`, qui
9
9
  // bâtit lui aussi des nœuds un par un et n'ouvre donc pas non plus de brèche.
10
10
  //
11
- // L'authentification tient dans un cookie de session HttpOnly posé par `/ui/login` :
12
- // ce script ne voit jamais le jeton et n'écrit rien dans le navigateur.
11
+ // L'authentification tient dans un cookie de session HttpOnly posé par `/ui/login` (ou
12
+ // `/ui/setup` à la création du compte) : ce script ne voit jamais l'identifiant de session.
13
+ // La seule valeur secrète qu'il affiche est une clé API juste créée, une fois, dans le volet
14
+ // des clés ; elle n'est gardée nulle part.
13
15
 
14
16
  import { renderMarkdown } from "./markdown.js";
15
17
 
@@ -39,10 +41,38 @@ const ICONS = {
39
41
  "M22 16h-3",
40
42
  ],
41
43
  search: ["m21 21-4.3-4.3", "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z"],
44
+ plus: ["M5 12h14", "M12 5v14"],
45
+ users: [
46
+ "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2",
47
+ "M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z",
48
+ "M22 21v-2a4 4 0 0 0-3-3.87",
49
+ "M16 3.13a4 4 0 0 1 0 7.75",
50
+ ],
51
+ arrowUpRight: ["M7 7h10v10", "M7 17 17 7"],
52
+ refresh: ["M3 12a9 9 0 0 1 15-6.7L21 8", "M21 3v5h-5", "M21 12a9 9 0 0 1-15 6.7L3 16", "M3 21v-5h5"],
53
+ ban: ["M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z", "m4.9 4.9 14.2 14.2"],
54
+ alert: ["m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3", "M12 9v4", "M12 17h.01"],
55
+ alertCircle: ["M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z", "M12 8v4", "M12 16h.01"],
56
+ circleCheck: ["M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z", "m9 12 2 2 4-4"],
57
+ timer: ["M10 2h4", "m12 14 3-3", "M12 22a8 8 0 1 0 0-16 8 8 0 0 0 0 16z"],
58
+ monitor: ["M4 3h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z", "M8 21h8", "M12 17v4"],
59
+ smartphone: ["M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z", "M12 18h.01"],
60
+ chevronDown: ["m6 9 6 6 6-6"],
61
+ };
62
+
63
+ /** Ce que la console sait de la connexion : le compte ouvert, ou s'il reste à créer. */
64
+ const auth = {
65
+ loggedIn: false,
66
+ /** Aucun compte n'existe : la console propose de le créer, avec le code d'installation. */
67
+ setupRequired: false,
68
+ user: null,
42
69
  };
43
70
 
44
71
  const state = {
45
- /** "threads" | "links" — l'écran affiché, choisi par les onglets de la barre du haut. */
72
+ /**
73
+ * "threads" | "links" | "keys" | "account" — l'écran affiché, choisi par les onglets de la
74
+ * barre du haut et par le menu du compte.
75
+ */
46
76
  view: "threads",
47
77
  /** Liaisons du serveur, relues à chaque rafraîchissement. */
48
78
  links: [],
@@ -89,13 +119,39 @@ const el = {
89
119
  autoRefresh: document.getElementById("auto-refresh"),
90
120
  refresh: document.getElementById("refresh"),
91
121
  logout: document.getElementById("logout"),
122
+ account: document.getElementById("account"),
123
+ accountButton: document.getElementById("account-button"),
124
+ accountMenu: document.getElementById("account-menu"),
125
+ accountInitial: document.getElementById("account-initial"),
126
+ accountName: document.getElementById("account-name"),
92
127
  login: document.getElementById("login"),
93
128
  loginForm: document.getElementById("login-form"),
94
129
  loginOrigin: document.getElementById("login-origin"),
95
- tokenField: document.querySelector("#login-form .field"),
96
- token: document.getElementById("token"),
97
- tokenReveal: document.getElementById("token-reveal"),
98
- tokenError: document.getElementById("token-error"),
130
+ loginUsername: document.getElementById("login-username"),
131
+ loginPassword: document.getElementById("login-password"),
132
+ loginRemember: document.getElementById("login-remember"),
133
+ loginError: document.getElementById("login-error"),
134
+ setup: document.getElementById("setup"),
135
+ setupForm: document.getElementById("setup-form"),
136
+ setupCode: document.getElementById("setup-code"),
137
+ setupUsername: document.getElementById("setup-username"),
138
+ setupPassword: document.getElementById("setup-password"),
139
+ setupConfirm: document.getElementById("setup-confirm"),
140
+ setupMeter: document.getElementById("setup-meter"),
141
+ setupMeterLabel: document.getElementById("setup-meter-label"),
142
+ setupError: document.getElementById("setup-error"),
143
+ setupSubmit: document.getElementById("setup-submit"),
144
+ keys: document.getElementById("keys"),
145
+ keyNew: document.getElementById("key-new"),
146
+ keyFilters: document.getElementById("key-filters"),
147
+ keySearch: document.getElementById("key-search"),
148
+ keyTable: document.getElementById("key-table"),
149
+ keyDrawer: document.getElementById("key-drawer"),
150
+ keyDrawerBody: document.getElementById("key-drawer-body"),
151
+ keyRevoke: document.getElementById("key-revoke"),
152
+ keyRevokeBody: document.getElementById("key-revoke-body"),
153
+ accountPage: document.getElementById("account-page"),
154
+ accountBody: document.getElementById("account-body"),
99
155
  console: document.getElementById("console"),
100
156
  agentsPane: document.getElementById("agents-pane"),
101
157
  agents: document.getElementById("agents"),
@@ -381,21 +437,71 @@ async function action(method, path, body) {
381
437
  throw new Error("unauthorized");
382
438
  }
383
439
  const payload = await response.json().catch(() => ({}));
384
- if (!response.ok) throw new Error(payload.error || response.statusText);
440
+ if (!response.ok) {
441
+ // Le statut voyage avec l'erreur : certains refus se disent mieux en clair qu'avec le
442
+ // message du serveur (mot de passe actuel faux, clé déjà révoquée…).
443
+ const error = new Error(payload.error || response.statusText);
444
+ error.status = response.status;
445
+ throw error;
446
+ }
385
447
  return payload;
386
448
  }
387
449
 
388
450
  function setLoggedIn(loggedIn) {
389
- el.login.hidden = loggedIn;
451
+ auth.loggedIn = loggedIn;
452
+ el.login.hidden = loggedIn || auth.setupRequired;
453
+ el.setup.hidden = loggedIn || !auth.setupRequired;
390
454
  el.console.hidden = !loggedIn || state.view !== "threads";
391
455
  el.links.hidden = !loggedIn || state.view !== "links";
456
+ el.keys.hidden = !loggedIn || state.view !== "keys";
457
+ el.accountPage.hidden = !loggedIn || state.view !== "account";
392
458
  el.tabs.hidden = !loggedIn;
393
- el.controls.hidden = !loggedIn;
459
+ el.account.hidden = !loggedIn;
460
+ // Rafraîchissement et nettoyage portent sur les boîtes : ils n'ont rien à faire ailleurs.
461
+ el.controls.hidden = !loggedIn || (state.view !== "threads" && state.view !== "links");
394
462
  // La recherche porte sur les fils ; le volet des liaisons a la sienne.
395
463
  el.searchBox.hidden = !loggedIn || state.view !== "threads";
396
464
  if (!loggedIn) {
397
- setStatus("idle", "non connecté");
398
- el.token.focus();
465
+ closeAccountMenu();
466
+ if (el.keyDrawer.open) el.keyDrawer.close();
467
+ if (el.keyRevoke.open) el.keyRevoke.close();
468
+ if (auth.setupRequired) {
469
+ setStatus("warn", "aucun compte créé");
470
+ el.setupCode.focus();
471
+ } else {
472
+ setStatus("idle", "non connecté");
473
+ el.loginUsername.focus();
474
+ }
475
+ }
476
+ }
477
+
478
+ /** Le compte ouvert, tel que le menu de la barre du haut le montre. */
479
+ function setAccountUser(user) {
480
+ auth.user = user;
481
+ el.accountName.textContent = user ? user.username : "";
482
+ el.accountInitial.textContent = user ? initials(user.username).slice(0, 1) : "";
483
+ }
484
+
485
+ /**
486
+ * Ce que la console affiche en arrivant : ses données si une session est ouverte, sinon la
487
+ * connexion — ou la création du compte, tant qu'il n'existe pas.
488
+ */
489
+ async function checkSession() {
490
+ const response = await fetch("/ui/session", {
491
+ headers: { accept: "application/json" },
492
+ credentials: "same-origin",
493
+ }).catch(() => null);
494
+ if (!response || !response.ok) {
495
+ setStatus("ko", "serveur injoignable");
496
+ return;
497
+ }
498
+ const payload = await response.json();
499
+ auth.setupRequired = Boolean(payload.setup_required);
500
+ if (payload.authenticated) {
501
+ setAccountUser(payload.user);
502
+ await refresh();
503
+ } else {
504
+ setLoggedIn(false);
399
505
  }
400
506
  }
401
507
 
@@ -1220,6 +1326,14 @@ async function refresh(options) {
1220
1326
  await refreshLinks();
1221
1327
  return;
1222
1328
  }
1329
+ if (state.view === "keys") {
1330
+ await refreshKeys();
1331
+ return;
1332
+ }
1333
+ if (state.view === "account") {
1334
+ await refreshAccount();
1335
+ return;
1336
+ }
1223
1337
  try {
1224
1338
  const [overview, list, links] = await Promise.all([
1225
1339
  api("/admin/overview"),
@@ -1542,6 +1656,7 @@ function renderLinksView() {
1542
1656
  }
1543
1657
 
1544
1658
  function setView(view) {
1659
+ closeAccountMenu();
1545
1660
  if (state.view === view) return;
1546
1661
  state.view = view;
1547
1662
  for (const tab of el.tabs.querySelectorAll(".tab")) {
@@ -2532,6 +2647,1131 @@ async function confirmPurge() {
2532
2647
  }
2533
2648
  }
2534
2649
 
2650
+ // --- Durées relatives ------------------------------------------------------
2651
+
2652
+ const DAY_MS = 24 * 60 * 60 * 1000;
2653
+
2654
+ /** Une clé dont l'échéance tombe dans ce délai est signalée dans la liste. */
2655
+ const EXPIRY_WARNING_DAYS = 7;
2656
+
2657
+ /** Passé lisible : « il y a 2 min », « hier 18:05 », « il y a 3 jours », puis la date. */
2658
+ function formatAgo(iso) {
2659
+ const date = new Date(iso);
2660
+ if (Number.isNaN(date.getTime())) return String(iso);
2661
+ const minutes = Math.floor((Date.now() - date.getTime()) / 60_000);
2662
+ if (minutes < 1) return "à l'instant";
2663
+ if (minutes < 60) return `il y a ${minutes} min`;
2664
+ const today = new Date();
2665
+ const midnight = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
2666
+ if (date.getTime() >= midnight) return `il y a ${Math.floor(minutes / 60)} h`;
2667
+ if (date.getTime() >= midnight - DAY_MS) {
2668
+ return `hier ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
2669
+ }
2670
+ const days = Math.ceil((midnight - date.getTime()) / DAY_MS);
2671
+ return days < 30 ? `il y a ${days} jours` : formatDay(iso);
2672
+ }
2673
+
2674
+ /** Futur lisible : « dans 5 jours », « dans 3 h ». */
2675
+ function formatUntil(iso) {
2676
+ const ms = new Date(iso).getTime() - Date.now();
2677
+ if (ms < DAY_MS) return `dans ${Math.max(1, Math.round(ms / 3_600_000))} h`;
2678
+ return `dans ${plural(Math.floor(ms / DAY_MS), "jour")}`;
2679
+ }
2680
+
2681
+ function daysLeft(iso) {
2682
+ return (new Date(iso).getTime() - Date.now()) / DAY_MS;
2683
+ }
2684
+
2685
+ // --- Formulaires -----------------------------------------------------------
2686
+
2687
+ /** Champ libellé : le libellé, la saisie, et l'aide en dessous s'il y en a une. */
2688
+ function formField(label, control, hint) {
2689
+ const wrapper = node("label", "field field--tight");
2690
+ wrapper.appendChild(node("span", "field-label", label));
2691
+ const box = node("span", "field-input");
2692
+ box.appendChild(control);
2693
+ wrapper.appendChild(box);
2694
+ if (hint) wrapper.appendChild(node("span", "field-hint", hint));
2695
+ return wrapper;
2696
+ }
2697
+
2698
+ function textInput(value, options = {}) {
2699
+ const input = document.createElement("input");
2700
+ input.type = options.type || "text";
2701
+ input.value = value || "";
2702
+ if (options.placeholder) input.placeholder = options.placeholder;
2703
+ if (options.autocomplete) input.autocomplete = options.autocomplete;
2704
+ if (options.list) input.setAttribute("list", options.list);
2705
+ input.spellcheck = false;
2706
+ return input;
2707
+ }
2708
+
2709
+ /** Bandeau d'erreur ou d'avertissement au-dessus du bouton d'un formulaire. */
2710
+ function showBanner(element, kind, message) {
2711
+ element.hidden = !message;
2712
+ element.className = `form-banner form-banner--${kind}`;
2713
+ element.replaceChildren();
2714
+ if (!message) return;
2715
+ element.appendChild(icon(kind === "warn" ? "timer" : "alertCircle"));
2716
+ element.appendChild(document.createTextNode(message));
2717
+ }
2718
+
2719
+ function formatWait(seconds) {
2720
+ if (seconds < 60) return `${seconds} s`;
2721
+ const minutes = Math.floor(seconds / 60);
2722
+ const rest = seconds % 60;
2723
+ return rest ? `${minutes} min ${String(rest).padStart(2, "0")} s` : `${minutes} min`;
2724
+ }
2725
+
2726
+ /** Ce qu'un refus de formulaire dit à l'utilisateur, selon le statut rendu. */
2727
+ async function formRefusal(response, messages) {
2728
+ if (!response) return "Serveur injoignable.";
2729
+ const payload = await response.json().catch(() => ({}));
2730
+ if (response.status === 429) {
2731
+ return `Trop de tentatives. Réessayez dans ${formatWait(payload.retry_after_seconds || 900)}.`;
2732
+ }
2733
+ return messages[response.status] || payload.error || response.statusText;
2734
+ }
2735
+
2736
+ /**
2737
+ * Robustesse d'un mot de passe, de 0 (vide) à 4. En deçà de la longueur minimale, il est
2738
+ * trop court quoi qu'il contienne ; au-delà, la variété des caractères et la longueur
2739
+ * comptent.
2740
+ */
2741
+ const PASSWORD_MIN = 12;
2742
+
2743
+ function passwordStrength(password) {
2744
+ if (!password) return { level: 0, label: `${PASSWORD_MIN} caractères minimum.` };
2745
+ const length = `${plural(password.length, "caractère")} (${PASSWORD_MIN} minimum)`;
2746
+ if (password.length < PASSWORD_MIN) return { level: 1, label: `Trop court · ${length}` };
2747
+ const classes = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((re) => re.test(password)).length;
2748
+ const score = Math.min(4, 1 + (classes >= 3 ? 1 : 0) + (password.length >= 16 ? 1 : 0) + (password.length >= 24 ? 1 : 0));
2749
+ const words = { 2: "Correct", 3: "Solide", 4: "Très solide" };
2750
+ return { level: Math.max(2, score), label: `${words[Math.max(2, score)]} · ${length}` };
2751
+ }
2752
+
2753
+ function renderMeter(meter, label, password) {
2754
+ const strength = passwordStrength(password);
2755
+ meter.dataset.level = String(strength.level);
2756
+ label.textContent = strength.label;
2757
+ label.classList.toggle("is-weak", strength.level === 1);
2758
+ label.classList.toggle("is-strong", strength.level >= 3);
2759
+ }
2760
+
2761
+ // --- Menu du compte --------------------------------------------------------
2762
+
2763
+ function closeAccountMenu() {
2764
+ el.accountMenu.hidden = true;
2765
+ el.accountButton.setAttribute("aria-expanded", "false");
2766
+ }
2767
+
2768
+ function toggleAccountMenu() {
2769
+ const open = el.accountMenu.hidden;
2770
+ el.accountMenu.hidden = !open;
2771
+ el.accountButton.setAttribute("aria-expanded", String(open));
2772
+ for (const item of el.accountMenu.querySelectorAll("[data-view]")) {
2773
+ item.classList.toggle("is-active", item.dataset.view === state.view);
2774
+ }
2775
+ }
2776
+
2777
+ // --- Clés API --------------------------------------------------------------
2778
+
2779
+ /**
2780
+ * Écran des clés : la liste, filtrée par état, et un volet qui sert à trois choses — créer
2781
+ * une clé, la montrer une fois créée, en voir le détail.
2782
+ */
2783
+ const keysView = {
2784
+ list: [],
2785
+ /** "active" | "expired" | "revoked" — la puce choisie au-dessus de la liste. */
2786
+ filter: "active",
2787
+ search: "",
2788
+ /** Clé dont le volet montre le détail ; null quand il est fermé ou sert à autre chose. */
2789
+ detailId: null,
2790
+ };
2791
+
2792
+ /** Durées proposées à la création et à la prolongation, en jours ; null = sans échéance. */
2793
+ const KEY_LIFETIMES = [
2794
+ { days: 30, label: "30 jours" },
2795
+ { days: 90, label: "90 jours" },
2796
+ { days: 365, label: "1 an" },
2797
+ { days: null, label: "Jamais" },
2798
+ ];
2799
+
2800
+ async function refreshKeys() {
2801
+ try {
2802
+ const [overview, keys] = await Promise.all([api("/admin/overview"), api("/admin/keys")]);
2803
+ setLoggedIn(true);
2804
+ setStatus("ok", `connecté · ${window.location.host}`);
2805
+ renderAgents(overview);
2806
+ keysView.list = keys.keys;
2807
+ renderKeys();
2808
+ } catch (error) {
2809
+ if (error.message === "unauthorized") {
2810
+ setStatus("idle", "authentification requise");
2811
+ return;
2812
+ }
2813
+ setStatus("ko", `erreur : ${error.message}`);
2814
+ }
2815
+ }
2816
+
2817
+ /** Noms d'agents connus : ceux des boîtes ouvertes et ceux des clés existantes. */
2818
+ function knownAgentNames() {
2819
+ const names = new Set();
2820
+ for (const box of state.directory.values()) if (box.agent_name) names.add(box.agent_name);
2821
+ for (const key of keysView.list) if (key.agent_name) names.add(key.agent_name);
2822
+ return [...names].sort((a, b) => a.localeCompare(b));
2823
+ }
2824
+
2825
+ function keyLabel(key) {
2826
+ return `${key.prefix}…${key.suffix}`;
2827
+ }
2828
+
2829
+ function agentDot(name) {
2830
+ return node("span", `key-dot avatar--c${colorIndex(name)}`);
2831
+ }
2832
+
2833
+ /** Agent d'une clé : sa pastille et son nom, ou « Tous les agents ». */
2834
+ function keyAgent(key) {
2835
+ const cell = node("span", "key-agent");
2836
+ if (key.agent_name) {
2837
+ cell.appendChild(agentDot(key.agent_name));
2838
+ cell.appendChild(node("span", null, key.agent_name));
2839
+ } else {
2840
+ cell.classList.add("is-muted");
2841
+ cell.appendChild(icon("users"));
2842
+ cell.appendChild(node("span", null, "Tous les agents"));
2843
+ }
2844
+ return cell;
2845
+ }
2846
+
2847
+ /** Échéance d'une clé, telle que la liste et le volet la montrent. */
2848
+ function keyExpiry(key) {
2849
+ if (key.status === "revoked") return node("span", "key-muted", `révoquée le ${formatDay(key.revoked_at)}`);
2850
+ if (key.status === "expired") return node("span", "key-muted", `expirée le ${formatDay(key.expires_at)}`);
2851
+ if (!key.expires_at) return node("span", "key-muted", "jamais");
2852
+ if (daysLeft(key.expires_at) <= EXPIRY_WARNING_DAYS) return tag("tag tag--warn", formatUntil(key.expires_at), "clock");
2853
+ return node("span", null, formatUntil(key.expires_at));
2854
+ }
2855
+
2856
+ function matchesKeySearch(key) {
2857
+ const search = keysView.search.toLowerCase();
2858
+ if (!search) return true;
2859
+ return [key.name, key.agent_name || "tous les agents", key.prefix, key.suffix].some((value) =>
2860
+ String(value).toLowerCase().includes(search)
2861
+ );
2862
+ }
2863
+
2864
+ function renderKeys() {
2865
+ const counts = { active: 0, expired: 0, revoked: 0 };
2866
+ for (const key of keysView.list) counts[key.status] += 1;
2867
+ for (const chip of el.keyFilters.querySelectorAll(".chip")) {
2868
+ chip.classList.toggle("is-active", chip.dataset.status === keysView.filter);
2869
+ chip.querySelector(".chip-count").textContent = String(counts[chip.dataset.status]);
2870
+ }
2871
+
2872
+ const shown = keysView.list.filter((key) => key.status === keysView.filter && matchesKeySearch(key));
2873
+ const head = node("div", "key-row key-row--head");
2874
+ for (const label of ["Nom", "Clé", "Agent autorisé", "Dernière utilisation", "Expiration", ""]) {
2875
+ head.appendChild(node("span", null, label));
2876
+ }
2877
+ const rows = shown.map(keyRow);
2878
+ if (rows.length === 0) {
2879
+ const empty = {
2880
+ active: "Aucune clé active. Créez-en une par poste ou par agent.",
2881
+ expired: "Aucune clé expirée.",
2882
+ revoked: "Aucune clé révoquée.",
2883
+ }[keysView.filter];
2884
+ rows.push(node("p", "key-empty", keysView.search ? "Aucune clé ne correspond à la recherche." : empty));
2885
+ }
2886
+ el.keyTable.replaceChildren(head, ...rows);
2887
+ }
2888
+
2889
+ function keyRow(key) {
2890
+ const row = node("div", "key-row");
2891
+ row.tabIndex = 0;
2892
+ row.setAttribute("role", "button");
2893
+ row.dataset.key = key.id;
2894
+ if (keysView.detailId === key.id && el.keyDrawer.open) row.classList.add("is-selected");
2895
+
2896
+ const name = node("span", "key-name");
2897
+ name.appendChild(node("strong", null, key.name));
2898
+ name.appendChild(node("span", "key-sub", `créée le ${formatDay(key.created_at)}`));
2899
+ row.appendChild(name);
2900
+ row.appendChild(node("span", "mono", keyLabel(key)));
2901
+ row.appendChild(keyAgent(key));
2902
+
2903
+ const used = node("span", "key-name");
2904
+ if (key.last_used_at) {
2905
+ const recent = Date.now() - new Date(key.last_used_at).getTime() < 3_600_000;
2906
+ used.appendChild(node("span", recent ? "key-recent" : null, formatAgo(key.last_used_at)));
2907
+ if (key.last_used_ip) used.appendChild(node("span", "key-sub", key.last_used_ip));
2908
+ } else {
2909
+ used.appendChild(node("span", "key-muted", "jamais utilisée"));
2910
+ }
2911
+ row.appendChild(used);
2912
+ row.appendChild(keyExpiry(key));
2913
+
2914
+ const actions = node("span", "key-actions");
2915
+ if (key.status === "active") {
2916
+ actions.appendChild(
2917
+ button("btn key-revoke", "Révoquer", (event) => {
2918
+ event.stopPropagation();
2919
+ openRevoke(key);
2920
+ })
2921
+ );
2922
+ }
2923
+ actions.appendChild(icon("chevron"));
2924
+ row.appendChild(actions);
2925
+
2926
+ row.addEventListener("click", () => openKeyDetail(key.id));
2927
+ row.addEventListener("keydown", (event) => {
2928
+ if (event.target !== row || (event.key !== "Enter" && event.key !== " ")) return;
2929
+ event.preventDefault();
2930
+ openKeyDetail(key.id);
2931
+ });
2932
+ return row;
2933
+ }
2934
+
2935
+ // Volet ----------------------------------------------------------------------
2936
+
2937
+ function openDrawer() {
2938
+ if (!el.keyDrawer.open) el.keyDrawer.showModal();
2939
+ }
2940
+
2941
+ function closeDrawer() {
2942
+ if (el.keyDrawer.open) el.keyDrawer.close();
2943
+ }
2944
+
2945
+ /** En-tête du volet : titre, sous-titre, bouton de fermeture. */
2946
+ function drawerHead(title, subtitle, titleNode) {
2947
+ const head = node("div", "drawer-head");
2948
+ const text = node("div", "drawer-title");
2949
+ text.appendChild(titleNode || node("h2", null, title));
2950
+ if (subtitle) text.appendChild(subtitle instanceof Node ? subtitle : node("p", "muted", subtitle));
2951
+ head.appendChild(text);
2952
+ const close = node("button", "btn btn--icon btn--ghost");
2953
+ close.type = "button";
2954
+ close.setAttribute("aria-label", "Fermer");
2955
+ close.appendChild(icon("close"));
2956
+ close.addEventListener("click", closeDrawer);
2957
+ head.appendChild(close);
2958
+ return head;
2959
+ }
2960
+
2961
+ function drawerFoot(...buttons) {
2962
+ const foot = node("div", "drawer-foot");
2963
+ for (const item of buttons) foot.appendChild(item);
2964
+ return foot;
2965
+ }
2966
+
2967
+ /** Choix parmi des puces : une seule retenue, celle que `selected` désigne. */
2968
+ function choiceChips(options, selected, onPick) {
2969
+ const group = node("div", "choice");
2970
+ for (const option of options) {
2971
+ const chip = button("choice-item", option.label, () => onPick(option));
2972
+ chip.classList.toggle("is-active", option === selected);
2973
+ chip.setAttribute("aria-pressed", String(option === selected));
2974
+ group.appendChild(chip);
2975
+ }
2976
+ return group;
2977
+ }
2978
+
2979
+ function openKeyCreate() {
2980
+ keysView.detailId = null;
2981
+ const form = { name: "", scope: "one", agent: "", lifetime: KEY_LIFETIMES[1] };
2982
+ const names = knownAgentNames();
2983
+
2984
+ const nameInput = textInput("", { placeholder: "Poste, machine ou usage", autocomplete: "off" });
2985
+ const agentInput = textInput("", { placeholder: "Nom de l'agent", autocomplete: "off", list: "key-agent-names" });
2986
+ const datalist = node("datalist");
2987
+ datalist.id = "key-agent-names";
2988
+ for (const name of names) {
2989
+ const option = document.createElement("option");
2990
+ option.value = name;
2991
+ datalist.appendChild(option);
2992
+ }
2993
+
2994
+ const agentMark = node("span", "key-dot");
2995
+ const newTag = node("span", "field-tag", "nouveau");
2996
+ function updateAgentMark() {
2997
+ const name = form.agent.trim();
2998
+ agentMark.className = `key-dot avatar--c${colorIndex(name || "?")}`;
2999
+ newTag.hidden = !name || names.includes(name);
3000
+ }
3001
+
3002
+ const error = node("p", "form-banner");
3003
+ error.hidden = true;
3004
+ const create = button("btn btn--primary", "Créer la clé", submit);
3005
+ const body = node("div", "drawer-content");
3006
+
3007
+ function render() {
3008
+ const agentField = node("div", "field field--tight");
3009
+ agentField.appendChild(node("span", "field-label", "Agent autorisé"));
3010
+ const scopes = [
3011
+ { id: "one", label: "Un seul agent" },
3012
+ { id: "all", label: "Tous les agents" },
3013
+ ];
3014
+ const segmented = node("div", "segmented");
3015
+ for (const scope of scopes) {
3016
+ const item = button("segmented-item", scope.label, () => {
3017
+ form.scope = scope.id;
3018
+ render();
3019
+ });
3020
+ item.classList.toggle("is-active", form.scope === scope.id);
3021
+ item.setAttribute("aria-pressed", String(form.scope === scope.id));
3022
+ segmented.appendChild(item);
3023
+ }
3024
+ agentField.appendChild(segmented);
3025
+ if (form.scope === "one") {
3026
+ const box = node("span", "field-input field-input--spaced");
3027
+ box.append(agentMark, agentInput, newTag);
3028
+ updateAgentMark();
3029
+ agentField.appendChild(box);
3030
+ agentField.appendChild(
3031
+ node(
3032
+ "span",
3033
+ "field-hint",
3034
+ "Les sessions ouvertes avec cette clé portent ce nom d'agent : MESSENGER_AGENT_NAME n'a plus à être posé côté client."
3035
+ )
3036
+ );
3037
+ } else {
3038
+ agentField.appendChild(
3039
+ node(
3040
+ "span",
3041
+ "field-hint",
3042
+ "La clé ouvre les boîtes de tous les agents. Chaque client donne alors son nom d'agent dans MESSENGER_AGENT_NAME."
3043
+ )
3044
+ );
3045
+ }
3046
+
3047
+ const lifetimeField = node("div", "field field--tight");
3048
+ lifetimeField.appendChild(node("span", "field-label", "Expiration"));
3049
+ lifetimeField.appendChild(
3050
+ choiceChips(KEY_LIFETIMES, form.lifetime, (option) => {
3051
+ form.lifetime = option;
3052
+ render();
3053
+ })
3054
+ );
3055
+ lifetimeField.appendChild(
3056
+ node(
3057
+ "span",
3058
+ "field-hint",
3059
+ form.lifetime.days === null
3060
+ ? "La clé reste valable jusqu'à sa révocation."
3061
+ : `Expire le ${formatDay(new Date(Date.now() + form.lifetime.days * DAY_MS).toISOString())}. Signalée dans la liste ${EXPIRY_WARNING_DAYS} jours avant.`
3062
+ )
3063
+ );
3064
+
3065
+ const focused = document.activeElement;
3066
+ body.replaceChildren(
3067
+ formField("Nom", nameInput, "Pour la reconnaître dans la liste : le poste, la machine, l'usage."),
3068
+ agentField,
3069
+ lifetimeField,
3070
+ error,
3071
+ datalist
3072
+ );
3073
+ // Le volet se reconstruit à chaque choix : la saisie en cours garde le curseur.
3074
+ if (focused === agentInput || focused === nameInput) focused.focus();
3075
+ create.disabled = !form.name.trim() || (form.scope === "one" && !form.agent.trim());
3076
+ }
3077
+
3078
+ const updateCreate = () => {
3079
+ create.disabled = !form.name.trim() || (form.scope === "one" && !form.agent.trim());
3080
+ };
3081
+ nameInput.addEventListener("input", () => {
3082
+ form.name = nameInput.value;
3083
+ updateCreate();
3084
+ });
3085
+ // La saisie du nom d'agent ne reconstruit pas le volet : elle ne touche qu'à sa pastille
3086
+ // et à la mention « nouveau », sans quoi le champ perdrait le focus à chaque lettre.
3087
+ agentInput.addEventListener("input", () => {
3088
+ form.agent = agentInput.value;
3089
+ updateAgentMark();
3090
+ updateCreate();
3091
+ });
3092
+
3093
+ async function submit() {
3094
+ create.disabled = true;
3095
+ showBanner(error, "error", "");
3096
+ try {
3097
+ const created = await post("/admin/keys", {
3098
+ name: form.name.trim(),
3099
+ agent_name: form.scope === "one" ? form.agent.trim() : null,
3100
+ lifetime_days: form.lifetime.days,
3101
+ });
3102
+ renderKeyReveal(created.key, created.secret, null);
3103
+ refreshKeys();
3104
+ } catch (failure) {
3105
+ showBanner(error, "error", failure.message);
3106
+ create.disabled = false;
3107
+ }
3108
+ }
3109
+
3110
+ const cancel = button("btn btn--link", "Annuler", closeDrawer);
3111
+ el.keyDrawerBody.replaceChildren(
3112
+ drawerHead("Nouvelle clé API", "Elle s'affichera une seule fois, juste après sa création."),
3113
+ body,
3114
+ drawerFoot(cancel, create)
3115
+ );
3116
+ render();
3117
+ openDrawer();
3118
+ nameInput.focus();
3119
+ }
3120
+
3121
+ /** Commande qui écrit l'entrée du client dans `.mcp.json`, clé et adresse comprises. */
3122
+ function setupCommand(secret) {
3123
+ return `npx -y @imenam/mcp-messenger --claude-setup-mcp --server-url ${window.location.origin} --api-key ${secret}`;
3124
+ }
3125
+
3126
+ function mcpJson(secret) {
3127
+ const entry = {
3128
+ mcpServers: {
3129
+ messenger: {
3130
+ command: "npx",
3131
+ args: ["-y", "@imenam/mcp-messenger"],
3132
+ env: { MESSENGER_SERVER_URL: window.location.origin, MESSENGER_API_KEY: secret },
3133
+ },
3134
+ },
3135
+ };
3136
+ // Le tableau des arguments tient sur une ligne, comme on l'écrit à la main.
3137
+ return JSON.stringify(entry, null, 2).replace(/\[\s+("-y"),\s+("[^"]+")\s+\]/, "[$1, $2]");
3138
+ }
3139
+
3140
+ /** Bouton « Copier » libellé : le geste principal de l'écran où la clé apparaît. */
3141
+ function copyLabelled(value, label) {
3142
+ const element = node("button", "btn btn--primary btn--small");
3143
+ element.type = "button";
3144
+ const render = (iconName, text) => element.replaceChildren(icon(iconName), document.createTextNode(text));
3145
+ render("copy", label);
3146
+ element.addEventListener("click", async () => {
3147
+ try {
3148
+ await navigator.clipboard.writeText(value);
3149
+ render("check", "Copiée");
3150
+ setTimeout(() => render("copy", label), 1500);
3151
+ } catch {
3152
+ // Presse-papiers refusé : la clé reste affichée à côté, sélectionnable.
3153
+ }
3154
+ });
3155
+ return element;
3156
+ }
3157
+
3158
+ /**
3159
+ * La clé en clair, une fois. Rien ne la garde : à la fermeture du volet, elle n'existe plus
3160
+ * que chez qui l'a copiée.
3161
+ */
3162
+ function renderKeyReveal(key, secret, replaced) {
3163
+ keysView.detailId = null;
3164
+ let tab = "json";
3165
+
3166
+ const summary = node("div", "notice notice--ok");
3167
+ summary.appendChild(icon("circleCheck"));
3168
+ const summaryText = node("div");
3169
+ summaryText.appendChild(node("strong", null, key.name));
3170
+ const scope = key.agent_name || "Tous les agents";
3171
+ const expiry = key.expires_at ? `expire le ${formatDay(key.expires_at)}` : "sans échéance";
3172
+ summaryText.appendChild(node("span", null, `${scope} · ${expiry}`));
3173
+ if (replaced) {
3174
+ summaryText.appendChild(
3175
+ node("span", null, `L'ancienne clé reste valable jusqu'au ${formatDate(replaced.expires_at)}.`)
3176
+ );
3177
+ }
3178
+ summary.appendChild(summaryText);
3179
+
3180
+ const keyField = node("div", "field field--tight");
3181
+ keyField.appendChild(node("span", "field-label", "Votre clé"));
3182
+ const keyBox = node("div", "secret");
3183
+ keyBox.appendChild(node("code", "secret-value", secret));
3184
+ keyBox.appendChild(copyLabelled(secret, "Copier"));
3185
+ keyField.appendChild(keyBox);
3186
+ const warning = node("p", "secret-warning");
3187
+ warning.appendChild(icon("alert"));
3188
+ warning.appendChild(
3189
+ document.createTextNode(
3190
+ "Le serveur n'en garde qu'une empreinte. Perdue, elle ne se retrouve pas : on la révoque et on en crée une autre."
3191
+ )
3192
+ );
3193
+ keyField.appendChild(warning);
3194
+
3195
+ const config = node("div", "field field--tight");
3196
+ config.appendChild(node("span", "field-label", "Configurer le client MCP"));
3197
+ const tabs = node("div", "subtabs");
3198
+ const code = node("div", "code-block");
3199
+ const hint = node("span", "field-hint");
3200
+
3201
+ function renderConfig() {
3202
+ tabs.replaceChildren(
3203
+ ...[
3204
+ { id: "json", label: ".mcp.json" },
3205
+ { id: "command", label: "Commande d'installation" },
3206
+ ].map((item) => {
3207
+ const tabButton = button("subtab", item.label, () => {
3208
+ tab = item.id;
3209
+ renderConfig();
3210
+ });
3211
+ tabButton.classList.toggle("is-active", tab === item.id);
3212
+ return tabButton;
3213
+ })
3214
+ );
3215
+ const text = tab === "json" ? mcpJson(secret) : setupCommand(secret);
3216
+ code.replaceChildren(node("pre", null, text), copyButton(text, "Copier"));
3217
+ hint.textContent =
3218
+ tab === "json"
3219
+ ? "La clé y figure en entier : le bloc copié est prêt à coller."
3220
+ : "À lancer dans le dossier du projet : elle écrit l'entrée messenger dans .mcp.json.";
3221
+ }
3222
+ renderConfig();
3223
+ config.appendChild(tabs);
3224
+ config.appendChild(code);
3225
+ config.appendChild(hint);
3226
+
3227
+ const body = node("div", "drawer-content");
3228
+ body.append(summary, keyField, config);
3229
+ el.keyDrawerBody.replaceChildren(
3230
+ drawerHead(replaced ? "Clé remplacée" : "Clé créée", "Copiez-la maintenant : elle ne sera plus jamais affichée."),
3231
+ body,
3232
+ drawerFoot(button("btn btn--primary", "J'ai copié la clé", closeDrawer))
3233
+ );
3234
+ openDrawer();
3235
+ }
3236
+
3237
+ async function openKeyDetail(id) {
3238
+ keysView.detailId = id;
3239
+ try {
3240
+ renderKeyDetail(await api(`/admin/keys/${encodeURIComponent(id)}`));
3241
+ openDrawer();
3242
+ renderKeys();
3243
+ } catch (error) {
3244
+ if (error.message !== "unauthorized") setStatus("ko", `erreur : ${error.message}`);
3245
+ }
3246
+ }
3247
+
3248
+ function renderKeyDetail({ key, mailboxes }) {
3249
+ let renaming = false;
3250
+ let extendOpen = false;
3251
+ const error = node("p", "form-banner");
3252
+ error.hidden = true;
3253
+
3254
+ const fail = (failure) => showBanner(error, "error", failure.message);
3255
+ const reload = async () => {
3256
+ await openKeyDetail(key.id);
3257
+ refreshKeys();
3258
+ };
3259
+
3260
+ function title() {
3261
+ const wrapper = node("div", "drawer-name");
3262
+ if (renaming) {
3263
+ const input = textInput(key.name, { autocomplete: "off" });
3264
+ input.className = "inline-input";
3265
+ // Entrée enregistre, puis la perte du focus qui suit voudrait enregistrer encore.
3266
+ let saved = false;
3267
+ const save = async () => {
3268
+ if (saved) return;
3269
+ saved = true;
3270
+ const name = input.value.trim();
3271
+ if (!name || name === key.name) {
3272
+ renaming = false;
3273
+ render();
3274
+ return;
3275
+ }
3276
+ try {
3277
+ await patch(`/admin/keys/${encodeURIComponent(key.id)}`, { name });
3278
+ await reload();
3279
+ } catch (failure) {
3280
+ saved = false;
3281
+ fail(failure);
3282
+ }
3283
+ };
3284
+ input.addEventListener("keydown", (event) => {
3285
+ if (event.key === "Enter") save();
3286
+ if (event.key === "Escape") {
3287
+ // Échap referme la saisie, pas le volet.
3288
+ event.preventDefault();
3289
+ event.stopPropagation();
3290
+ saved = true;
3291
+ renaming = false;
3292
+ render();
3293
+ }
3294
+ });
3295
+ input.addEventListener("blur", save);
3296
+ wrapper.appendChild(input);
3297
+ setTimeout(() => input.focus(), 0);
3298
+ return wrapper;
3299
+ }
3300
+ wrapper.appendChild(node("h2", null, key.name));
3301
+ const rename = node("button", "btn btn--icon btn--ghost");
3302
+ rename.type = "button";
3303
+ rename.title = "Renommer";
3304
+ rename.setAttribute("aria-label", "Renommer la clé");
3305
+ rename.appendChild(icon("pencil"));
3306
+ rename.addEventListener("click", () => {
3307
+ renaming = true;
3308
+ render();
3309
+ });
3310
+ wrapper.appendChild(rename);
3311
+ return wrapper;
3312
+ }
3313
+
3314
+ function states() {
3315
+ const row = node("div", "drawer-tags");
3316
+ const labels = { active: "active", expired: "expirée", revoked: "révoquée" };
3317
+ const status = node("span", `tag tag--${key.status === "active" ? "ok" : "muted"}`);
3318
+ if (key.status === "active") status.appendChild(node("span", "pill-dot"));
3319
+ status.appendChild(document.createTextNode(labels[key.status]));
3320
+ row.appendChild(status);
3321
+ if (key.status === "active" && key.expires_at && daysLeft(key.expires_at) <= EXPIRY_WARNING_DAYS) {
3322
+ row.appendChild(tag("tag tag--warn", `expire ${formatUntil(key.expires_at)}`, "clock"));
3323
+ }
3324
+ return row;
3325
+ }
3326
+
3327
+ function extendControl() {
3328
+ const wrapper = node("span", "split");
3329
+ const extend = async (days) => {
3330
+ try {
3331
+ await post(`/admin/keys/${encodeURIComponent(key.id)}/extend`, { days });
3332
+ await reload();
3333
+ } catch (failure) {
3334
+ fail(failure);
3335
+ }
3336
+ };
3337
+ wrapper.appendChild(button("btn btn--small split-main", "Prolonger de 90 jours", () => extend(90)));
3338
+ const more = node("button", "btn btn--small split-more");
3339
+ more.type = "button";
3340
+ more.setAttribute("aria-label", "Autres durées");
3341
+ more.setAttribute("aria-expanded", String(extendOpen));
3342
+ more.appendChild(icon("chevronDown"));
3343
+ more.addEventListener("click", () => {
3344
+ extendOpen = !extendOpen;
3345
+ render();
3346
+ });
3347
+ wrapper.appendChild(more);
3348
+ if (extendOpen) {
3349
+ const menu = node("div", "split-menu");
3350
+ for (const option of [
3351
+ { days: 30, label: "Prolonger de 30 jours" },
3352
+ { days: 365, label: "Prolonger d'un an" },
3353
+ { days: null, label: "Retirer l'échéance" },
3354
+ ]) {
3355
+ menu.appendChild(button("split-item", option.label, () => extend(option.days)));
3356
+ }
3357
+ wrapper.appendChild(menu);
3358
+ }
3359
+ return wrapper;
3360
+ }
3361
+
3362
+ function properties() {
3363
+ const list = node("dl", "props");
3364
+ const add = (label, value) => {
3365
+ list.appendChild(node("dt", null, label));
3366
+ const cell = node("dd");
3367
+ for (const part of [].concat(value)) cell.appendChild(typeof part === "string" ? document.createTextNode(part) : part);
3368
+ list.appendChild(cell);
3369
+ };
3370
+ add("Agent autorisé", keyAgent(key));
3371
+ add("Créée", `${formatDay(key.created_at)}${key.created_by ? ` par ${key.created_by}` : ""}`);
3372
+ add(
3373
+ "Dernière utilisation",
3374
+ key.last_used_at ? `${formatAgo(key.last_used_at)}${key.last_used_ip ? ` · ${key.last_used_ip}` : ""}` : "jamais"
3375
+ );
3376
+ const expiry = key.expires_at ? formatDay(key.expires_at) : "sans échéance";
3377
+ add("Expiration", key.status === "revoked" ? [expiry] : [node("span", "props-value", expiry), extendControl()]);
3378
+ if (key.revoked_at) add("Révoquée", formatDate(key.revoked_at));
3379
+ return list;
3380
+ }
3381
+
3382
+ function boxes() {
3383
+ const section = node("section", "drawer-section");
3384
+ const head = node("div", "drawer-section-head");
3385
+ head.appendChild(node("h3", null, "Boîtes ouvertes avec cette clé"));
3386
+ head.appendChild(node("span", "muted", String(mailboxes.length)));
3387
+ section.appendChild(head);
3388
+ if (mailboxes.length === 0) {
3389
+ section.appendChild(node("p", "key-muted", "Aucune boîte n'a encore été ouverte avec cette clé."));
3390
+ return section;
3391
+ }
3392
+ const list = node("ul", "key-boxes");
3393
+ for (const box of mailboxes) {
3394
+ const item = node("li");
3395
+ const line = button("key-box", "", () => {
3396
+ closeDrawer();
3397
+ state.selection = { kind: "box", key: box.id };
3398
+ state.threadId = null;
3399
+ setView("threads");
3400
+ });
3401
+ line.title = "Voir ses fils";
3402
+ line.appendChild(avatarFor(box.agent_name || box.id, box.display_name || box.agent_name || box.id, box.id));
3403
+ const text = node("span", "key-name");
3404
+ text.appendChild(node("strong", null, box.display_name || "boîte sans nom"));
3405
+ const activity = box.last_activity_at ? `activité ${formatShort(box.last_activity_at)}` : "aucune activité";
3406
+ text.appendChild(node("span", "key-sub", `${box.id} · ${activity}`));
3407
+ line.appendChild(text);
3408
+ line.appendChild(icon("arrowUpRight"));
3409
+ item.appendChild(line);
3410
+ list.appendChild(item);
3411
+ }
3412
+ section.appendChild(list);
3413
+ return section;
3414
+ }
3415
+
3416
+ function rotate() {
3417
+ const section = node("section", "drawer-section drawer-row");
3418
+ const text = node("div");
3419
+ text.appendChild(node("h3", null, "Remplacer la clé"));
3420
+ text.appendChild(
3421
+ node("p", "key-muted", "Nouvelle clé, mêmes réglages. L'ancienne reste valable 24 h, le temps de mettre le client à jour.")
3422
+ );
3423
+ section.appendChild(text);
3424
+ const action = node("button", "btn");
3425
+ action.type = "button";
3426
+ action.append(icon("refresh"), document.createTextNode("Remplacer"));
3427
+ action.addEventListener("click", async () => {
3428
+ action.disabled = true;
3429
+ try {
3430
+ const rotated = await post(`/admin/keys/${encodeURIComponent(key.id)}/rotate`, {});
3431
+ renderKeyReveal(rotated.key, rotated.secret, rotated.replaced);
3432
+ refreshKeys();
3433
+ } catch (failure) {
3434
+ action.disabled = false;
3435
+ fail(failure);
3436
+ }
3437
+ });
3438
+ section.appendChild(action);
3439
+ return section;
3440
+ }
3441
+
3442
+ function danger() {
3443
+ const section = node("section", "danger-zone");
3444
+ const text = node("div");
3445
+ text.appendChild(node("h3", null, "Révoquer la clé"));
3446
+ text.appendChild(node("p", null, "Refusée dès l'appel suivant. Définitif."));
3447
+ section.appendChild(text);
3448
+ section.appendChild(button("btn btn--danger", "Révoquer…", () => openRevoke(key, mailboxes.length)));
3449
+ return section;
3450
+ }
3451
+
3452
+ function render() {
3453
+ const subtitle = node("p", "mono muted", keyLabel(key));
3454
+ const body = node("div", "drawer-content");
3455
+ body.append(states(), properties(), boxes());
3456
+ if (key.status === "active") body.appendChild(rotate());
3457
+ if (key.status !== "revoked") body.appendChild(danger());
3458
+ body.appendChild(error);
3459
+ el.keyDrawerBody.replaceChildren(drawerHead(null, subtitle, title()), body);
3460
+ }
3461
+
3462
+ render();
3463
+ }
3464
+
3465
+ /** Confirmation de révocation : ce qui s'arrête, ce qui reste, et que c'est définitif. */
3466
+ function openRevoke(key, mailboxCount = key.mailboxes) {
3467
+ const card = el.keyRevokeBody;
3468
+ const head = node("div", "confirm-head");
3469
+ const badge = node("span", "confirm-badge");
3470
+ badge.appendChild(icon("ban"));
3471
+ head.appendChild(badge);
3472
+ const title = node("div");
3473
+ title.appendChild(node("h2", null, `Révoquer « ${key.name} » ?`));
3474
+ title.appendChild(node("p", "mono muted", `${keyLabel(key)} · ${key.agent_name || "Tous les agents"}`));
3475
+ head.appendChild(title);
3476
+
3477
+ const consequences = node("ul", "confirm-list");
3478
+ const lost = node("li", "is-lost");
3479
+ lost.appendChild(icon("close"));
3480
+ lost.appendChild(
3481
+ document.createTextNode(
3482
+ mailboxCount > 0
3483
+ ? `${mailboxCount > 1 ? `Les ${mailboxCount} boîtes ouvertes` : "La boîte ouverte"} avec elle ${mailboxCount > 1 ? "perdent" : "perd"} l'accès dès leur prochain appel.`
3484
+ : "Les sessions qui l'utilisent perdent l'accès dès leur prochain appel."
3485
+ )
3486
+ );
3487
+ const kept = node("li", "is-kept");
3488
+ kept.appendChild(icon("check"));
3489
+ kept.appendChild(document.createTextNode("Leurs messages restent lisibles dans Fils."));
3490
+ consequences.append(lost, kept);
3491
+
3492
+ const error = node("p", "form-banner");
3493
+ error.hidden = true;
3494
+ const confirm = button("btn btn--danger", "Révoquer la clé", async () => {
3495
+ confirm.disabled = true;
3496
+ try {
3497
+ await post(`/admin/keys/${encodeURIComponent(key.id)}/revoke`, {});
3498
+ el.keyRevoke.close();
3499
+ if (el.keyDrawer.open && keysView.detailId === key.id) await openKeyDetail(key.id);
3500
+ await refreshKeys();
3501
+ } catch (failure) {
3502
+ confirm.disabled = false;
3503
+ showBanner(error, "error", failure.message);
3504
+ }
3505
+ });
3506
+ const foot = node("div", "modal-foot");
3507
+ foot.append(button("btn btn--link", "Annuler", () => el.keyRevoke.close()), confirm);
3508
+
3509
+ card.replaceChildren(head, consequences, node("p", "confirm-note", "Une clé révoquée ne se réactive pas."), error, foot);
3510
+ el.keyRevoke.showModal();
3511
+ confirm.focus();
3512
+ }
3513
+
3514
+ // --- Mon compte ------------------------------------------------------------
3515
+
3516
+ /** Navigateur et système lus dans l'en-tête `User-Agent`, assez pour reconnaître un appareil. */
3517
+ function describeDevice(userAgent) {
3518
+ const ua = String(userAgent || "");
3519
+ const browser = /Edg\//.test(ua)
3520
+ ? "Edge"
3521
+ : /OPR\//.test(ua)
3522
+ ? "Opera"
3523
+ : /Firefox\//.test(ua)
3524
+ ? "Firefox"
3525
+ : /Chrome\//.test(ua)
3526
+ ? "Chrome"
3527
+ : /Safari\//.test(ua)
3528
+ ? "Safari"
3529
+ : "Navigateur";
3530
+ const system = /Android/.test(ua)
3531
+ ? "Android"
3532
+ : /iPhone|iPad/.test(ua)
3533
+ ? "iOS"
3534
+ : /Windows/.test(ua)
3535
+ ? "Windows"
3536
+ : /Mac OS X/.test(ua)
3537
+ ? "macOS"
3538
+ : /Linux/.test(ua)
3539
+ ? "Linux"
3540
+ : null;
3541
+ return {
3542
+ label: system ? `${browser} · ${system}` : browser,
3543
+ mobile: /Mobi|Android|iPhone|iPad/.test(ua),
3544
+ };
3545
+ }
3546
+
3547
+ const accountView = {
3548
+ data: null,
3549
+ renaming: false,
3550
+ /** Résultat de la dernière action, montré sous la carte concernée. */
3551
+ notice: { identity: null, password: null, sessions: null },
3552
+ };
3553
+
3554
+ async function refreshAccount() {
3555
+ try {
3556
+ const data = await api("/admin/account");
3557
+ setLoggedIn(true);
3558
+ setStatus("ok", `connecté · ${window.location.host}`);
3559
+ accountView.data = data;
3560
+ setAccountUser(data.user);
3561
+ renderAccount();
3562
+ } catch (error) {
3563
+ if (error.message === "unauthorized") {
3564
+ setStatus("idle", "authentification requise");
3565
+ return;
3566
+ }
3567
+ setStatus("ko", `erreur : ${error.message}`);
3568
+ }
3569
+ }
3570
+
3571
+ function accountNotice(slot) {
3572
+ const notice = accountView.notice[slot];
3573
+ const element = node("p", "form-banner");
3574
+ if (!notice) {
3575
+ element.hidden = true;
3576
+ return element;
3577
+ }
3578
+ if (notice.kind === "ok") {
3579
+ element.className = "form-banner form-banner--ok";
3580
+ element.append(icon("circleCheck"), document.createTextNode(notice.message));
3581
+ return element;
3582
+ }
3583
+ showBanner(element, notice.kind, notice.message);
3584
+ return element;
3585
+ }
3586
+
3587
+ function renderAccount() {
3588
+ const { user, sessions } = accountView.data;
3589
+ el.accountBody.replaceChildren(identityCard(user), passwordCard(), sessionsCard(sessions));
3590
+ }
3591
+
3592
+ function identityCard(user) {
3593
+ const card = node("section", "panel-card panel-card--row");
3594
+ card.appendChild(node("span", "account-avatar account-avatar--lg", initials(user.username).slice(0, 1)));
3595
+ const text = node("div", "panel-card-main");
3596
+ if (accountView.renaming) {
3597
+ const input = textInput(user.username, { autocomplete: "username" });
3598
+ input.className = "inline-input";
3599
+ const save = async () => {
3600
+ const username = input.value.trim();
3601
+ if (!username || username === user.username) {
3602
+ accountView.renaming = false;
3603
+ renderAccount();
3604
+ return;
3605
+ }
3606
+ try {
3607
+ await patch("/admin/account", { username });
3608
+ accountView.renaming = false;
3609
+ accountView.notice.identity = { kind: "ok", message: `Identifiant changé : ${username}.` };
3610
+ await refreshAccount();
3611
+ } catch (failure) {
3612
+ accountView.notice.identity = {
3613
+ kind: "error",
3614
+ message: failure.status === 409 ? "Cet identifiant est déjà pris." : failure.message,
3615
+ };
3616
+ renderAccount();
3617
+ }
3618
+ };
3619
+ input.addEventListener("keydown", (event) => {
3620
+ if (event.key === "Enter") save();
3621
+ if (event.key === "Escape") {
3622
+ accountView.renaming = false;
3623
+ renderAccount();
3624
+ }
3625
+ });
3626
+ text.appendChild(input);
3627
+ setTimeout(() => input.focus(), 0);
3628
+ } else {
3629
+ text.appendChild(node("strong", "panel-card-title", user.username));
3630
+ }
3631
+ text.appendChild(node("span", "key-sub", `Administrateur · compte créé le ${formatDay(user.created_at)}`));
3632
+ text.appendChild(accountNotice("identity"));
3633
+ card.appendChild(text);
3634
+ if (!accountView.renaming) {
3635
+ card.appendChild(
3636
+ button("btn btn--link", "Changer l'identifiant", () => {
3637
+ accountView.renaming = true;
3638
+ accountView.notice.identity = null;
3639
+ renderAccount();
3640
+ })
3641
+ );
3642
+ }
3643
+ return card;
3644
+ }
3645
+
3646
+ function passwordCard() {
3647
+ const card = node("section", "panel-card");
3648
+ card.appendChild(node("h3", null, "Mot de passe"));
3649
+ card.appendChild(
3650
+ node(
3651
+ "p",
3652
+ "key-muted",
3653
+ "Le changer déconnecte toutes les autres sessions de la console. Les clés API ne sont pas touchées."
3654
+ )
3655
+ );
3656
+
3657
+ const current = textInput("", { type: "password", autocomplete: "current-password" });
3658
+ const next = textInput("", { type: "password", autocomplete: "new-password" });
3659
+ const confirm = textInput("", { type: "password", autocomplete: "new-password" });
3660
+ const meter = node("span", "meter");
3661
+ meter.dataset.level = "0";
3662
+ for (let i = 0; i < 4; i += 1) meter.appendChild(node("span"));
3663
+ const meterLabel = node("span", "field-hint", `${PASSWORD_MIN} caractères minimum.`);
3664
+
3665
+ const nextField = formField("Nouveau mot de passe", next);
3666
+ nextField.append(meter, meterLabel);
3667
+ const grid = node("div", "form-grid");
3668
+ const currentField = formField("Mot de passe actuel", current);
3669
+ currentField.classList.add("form-grid-full");
3670
+ grid.append(currentField, nextField, formField("Confirmer", confirm));
3671
+
3672
+ const submit = button("btn btn--primary", "Changer le mot de passe", async () => {
3673
+ submit.disabled = true;
3674
+ try {
3675
+ const result = await post("/admin/account/password", {
3676
+ current_password: current.value,
3677
+ new_password: next.value,
3678
+ });
3679
+ accountView.notice.password = {
3680
+ kind: "ok",
3681
+ message:
3682
+ result.closed_sessions > 0
3683
+ ? `Mot de passe changé · ${plural(result.closed_sessions, "autre session fermée", "autres sessions fermées")}.`
3684
+ : "Mot de passe changé.",
3685
+ };
3686
+ await refreshAccount();
3687
+ } catch (failure) {
3688
+ accountView.notice.password = {
3689
+ kind: failure.status === 429 ? "warn" : "error",
3690
+ message:
3691
+ failure.status === 403
3692
+ ? "Mot de passe actuel incorrect."
3693
+ : failure.status === 429
3694
+ ? "Trop de tentatives. Réessayez dans quelques minutes."
3695
+ : failure.message,
3696
+ };
3697
+ renderAccount();
3698
+ }
3699
+ });
3700
+ const update = () => {
3701
+ renderMeter(meter, meterLabel, next.value);
3702
+ submit.disabled = !current.value || next.value.length < PASSWORD_MIN || next.value !== confirm.value;
3703
+ };
3704
+ for (const input of [current, next, confirm]) input.addEventListener("input", update);
3705
+ update();
3706
+
3707
+ const foot = node("div", "panel-card-foot");
3708
+ foot.appendChild(submit);
3709
+ card.append(grid, accountNotice("password"), foot);
3710
+ return card;
3711
+ }
3712
+
3713
+ function sessionsCard(sessions) {
3714
+ const card = node("section", "panel-card panel-card--flush");
3715
+ const head = node("div", "panel-card-head");
3716
+ head.appendChild(node("h3", null, "Sessions de la console"));
3717
+ head.appendChild(node("p", "key-muted", "Les navigateurs connectés avec votre mot de passe."));
3718
+ card.appendChild(head);
3719
+
3720
+ const list = node("ul", "session-list");
3721
+ for (const session of sessions) {
3722
+ const device = describeDevice(session.user_agent);
3723
+ const item = node("li", "session");
3724
+ item.appendChild(icon(device.mobile ? "smartphone" : "monitor"));
3725
+ const text = node("div", "key-name");
3726
+ text.appendChild(node("strong", null, device.label));
3727
+ const detail = session.current
3728
+ ? `connecté depuis le ${formatDay(session.created_at)} · expire le ${formatDay(session.expires_at)}`
3729
+ : `vue ${formatAgo(session.last_seen_at)}`;
3730
+ text.appendChild(node("span", "key-sub", [session.ip, detail].filter(Boolean).join(" · ")));
3731
+ item.appendChild(text);
3732
+ if (session.current) {
3733
+ item.appendChild(node("span", "tag tag--ok", "cet appareil"));
3734
+ } else {
3735
+ item.appendChild(
3736
+ button("btn btn--small btn--danger-text", "Déconnecter", async () => {
3737
+ try {
3738
+ await remove(`/admin/account/sessions/${encodeURIComponent(session.id)}`);
3739
+ accountView.notice.sessions = { kind: "ok", message: `${device.label} déconnecté.` };
3740
+ await refreshAccount();
3741
+ } catch (failure) {
3742
+ accountView.notice.sessions = { kind: "error", message: failure.message };
3743
+ renderAccount();
3744
+ }
3745
+ })
3746
+ );
3747
+ }
3748
+ list.appendChild(item);
3749
+ }
3750
+ card.appendChild(list);
3751
+
3752
+ const foot = node("div", "panel-card-foot panel-card-foot--start");
3753
+ if (sessions.some((session) => !session.current)) {
3754
+ foot.appendChild(
3755
+ button("btn btn--link btn--danger-text", "Déconnecter toutes les autres sessions", async () => {
3756
+ try {
3757
+ const result = await post("/admin/account/sessions/revoke-others", {});
3758
+ accountView.notice.sessions = {
3759
+ kind: "ok",
3760
+ message: `${plural(result.closed_sessions, "session fermée", "sessions fermées")}.`,
3761
+ };
3762
+ await refreshAccount();
3763
+ } catch (failure) {
3764
+ accountView.notice.sessions = { kind: "error", message: failure.message };
3765
+ renderAccount();
3766
+ }
3767
+ })
3768
+ );
3769
+ }
3770
+ foot.appendChild(accountNotice("sessions"));
3771
+ card.appendChild(foot);
3772
+ return card;
3773
+ }
3774
+
2535
3775
  // --- Écouteurs -------------------------------------------------------------
2536
3776
 
2537
3777
  el.purgeOpen.addEventListener("click", openPurge);
@@ -2592,41 +3832,141 @@ el.search.addEventListener("input", (event) => {
2592
3832
  searchTimer = setTimeout(() => refresh({ keepThread: true }), 250);
2593
3833
  });
2594
3834
 
2595
- el.tokenReveal.addEventListener("click", () => {
2596
- const shown = el.token.type === "text";
2597
- el.token.type = shown ? "password" : "text";
2598
- el.tokenReveal.setAttribute("aria-label", shown ? "Afficher le jeton" : "Masquer le jeton");
2599
- el.token.focus();
2600
- });
3835
+ // L'œil d'un champ de mot de passe le montre ou le masque, sans quitter le champ.
3836
+ for (const reveal of document.querySelectorAll("[data-reveal]")) {
3837
+ reveal.addEventListener("click", () => {
3838
+ const input = document.getElementById(reveal.dataset.reveal);
3839
+ const shown = input.type === "text";
3840
+ input.type = shown ? "password" : "text";
3841
+ reveal.setAttribute("aria-label", shown ? "Afficher le mot de passe" : "Masquer le mot de passe");
3842
+ input.focus();
3843
+ });
3844
+ }
2601
3845
 
2602
- el.loginForm.addEventListener("submit", async (event) => {
2603
- event.preventDefault();
2604
- el.tokenError.textContent = "";
2605
- el.tokenField.classList.remove("is-invalid");
2606
- const token = el.token.value.trim();
2607
- if (!token) return;
2608
- const response = await fetch("/ui/login", {
3846
+ function postForm(path, body) {
3847
+ return fetch(path, {
2609
3848
  method: "POST",
2610
- headers: { "content-type": "application/json" },
3849
+ headers: { "content-type": "application/json", accept: "application/json" },
2611
3850
  credentials: "same-origin",
2612
- body: JSON.stringify({ token }),
3851
+ body: JSON.stringify(body),
2613
3852
  }).catch(() => null);
2614
- // Le champ est vidé dans tous les cas : le jeton ne reste pas affiché dans la page.
2615
- el.token.value = "";
2616
- el.token.type = "password";
3853
+ }
3854
+
3855
+ el.loginForm.addEventListener("submit", async (event) => {
3856
+ event.preventDefault();
3857
+ showBanner(el.loginError, "error", "");
3858
+ const username = el.loginUsername.value.trim();
3859
+ const password = el.loginPassword.value;
3860
+ if (!username || !password) return;
3861
+ const response = await postForm("/ui/login", { username, password, remember: el.loginRemember.checked });
3862
+ // Le mot de passe ne reste pas dans la page, qu'il ait été accepté ou non.
3863
+ el.loginPassword.value = "";
3864
+ el.loginPassword.type = "password";
2617
3865
  if (!response || !response.ok) {
2618
- el.tokenField.classList.add("is-invalid");
2619
- el.tokenError.textContent =
2620
- response && response.status === 429
2621
- ? "Trop de tentatives, réessayez dans quelques minutes."
2622
- : "Jeton refusé. Vérifiez MESSENGER_ADMIN_TOKEN côté serveur.";
3866
+ const message = await formRefusal(response, { 401: "Nom d'utilisateur ou mot de passe incorrect." });
3867
+ showBanner(el.loginError, response && response.status === 429 ? "warn" : "error", message);
3868
+ el.loginPassword.focus();
2623
3869
  return;
2624
3870
  }
2625
- refresh();
3871
+ await checkSession();
3872
+ });
3873
+
3874
+ function updateSetupForm() {
3875
+ renderMeter(el.setupMeter, el.setupMeterLabel, el.setupPassword.value);
3876
+ el.setupSubmit.disabled =
3877
+ !el.setupCode.value.trim() ||
3878
+ !el.setupUsername.value.trim() ||
3879
+ el.setupPassword.value.length < PASSWORD_MIN ||
3880
+ el.setupPassword.value !== el.setupConfirm.value;
3881
+ }
3882
+
3883
+ for (const input of [el.setupCode, el.setupUsername, el.setupPassword, el.setupConfirm]) {
3884
+ input.addEventListener("input", updateSetupForm);
3885
+ }
3886
+
3887
+ el.setupForm.addEventListener("submit", async (event) => {
3888
+ event.preventDefault();
3889
+ showBanner(el.setupError, "error", "");
3890
+ if (el.setupSubmit.disabled) return;
3891
+ const response = await postForm("/ui/setup", {
3892
+ code: el.setupCode.value.trim(),
3893
+ username: el.setupUsername.value.trim(),
3894
+ password: el.setupPassword.value,
3895
+ });
3896
+ if (!response || !response.ok) {
3897
+ // Le compte a été créé entre-temps, ailleurs : il ne reste qu'à s'y connecter.
3898
+ if (response && response.status === 409) {
3899
+ auth.setupRequired = false;
3900
+ setLoggedIn(false);
3901
+ return;
3902
+ }
3903
+ const message = await formRefusal(response, {
3904
+ 400: "Vérifiez les champs : un identifiant sans espace, un mot de passe de 12 caractères au moins.",
3905
+ 403: "Code d'installation incorrect. Recopiez celui du journal du serveur.",
3906
+ });
3907
+ showBanner(el.setupError, response && response.status === 429 ? "warn" : "error", message);
3908
+ return;
3909
+ }
3910
+ el.setupPassword.value = "";
3911
+ el.setupConfirm.value = "";
3912
+ auth.setupRequired = false;
3913
+ await checkSession();
3914
+ });
3915
+
3916
+ el.accountButton.addEventListener("click", (event) => {
3917
+ event.stopPropagation();
3918
+ toggleAccountMenu();
3919
+ });
3920
+
3921
+ el.accountMenu.addEventListener("click", (event) => {
3922
+ const item = event.target.closest("[data-view]");
3923
+ if (item) setView(item.dataset.view);
3924
+ });
3925
+
3926
+ document.addEventListener("click", (event) => {
3927
+ if (!el.accountMenu.hidden && !el.account.contains(event.target)) closeAccountMenu();
3928
+ });
3929
+
3930
+ document.addEventListener("keydown", (event) => {
3931
+ if (event.key === "Escape" && !el.accountMenu.hidden) {
3932
+ closeAccountMenu();
3933
+ el.accountButton.focus();
3934
+ }
3935
+ });
3936
+
3937
+ el.keyNew.addEventListener("click", openKeyCreate);
3938
+
3939
+ el.keyFilters.addEventListener("click", (event) => {
3940
+ const chip = event.target.closest(".chip");
3941
+ if (!chip) return;
3942
+ keysView.filter = chip.dataset.status;
3943
+ renderKeys();
3944
+ });
3945
+
3946
+ el.keySearch.addEventListener("input", () => {
3947
+ keysView.search = el.keySearch.value.trim();
3948
+ renderKeys();
3949
+ });
3950
+
3951
+ el.keyDrawer.addEventListener("close", () => {
3952
+ // La clé montrée une fois disparaît avec le volet : rien n'en reste dans la page.
3953
+ el.keyDrawerBody.replaceChildren();
3954
+ keysView.detailId = null;
3955
+ if (state.view === "keys") renderKeys();
3956
+ });
3957
+
3958
+ // Un clic sur le voile, hors du volet, le referme.
3959
+ el.keyDrawer.addEventListener("click", (event) => {
3960
+ if (event.target === el.keyDrawer) closeDrawer();
2626
3961
  });
2627
3962
 
2628
3963
  el.logout.addEventListener("click", async () => {
2629
3964
  await fetch("/ui/logout", { method: "POST", credentials: "same-origin" }).catch(() => null);
3965
+ setAccountUser(null);
3966
+ keysView.list = [];
3967
+ accountView.data = null;
3968
+ el.keyTable.replaceChildren();
3969
+ el.accountBody.replaceChildren();
2630
3970
  state.selection = { kind: "all", key: null };
2631
3971
  state.expanded = new Set();
2632
3972
  state.showEmpty = new Set();
@@ -2645,11 +3985,13 @@ el.logout.addEventListener("click", async () => {
2645
3985
  setLoggedIn(false);
2646
3986
  });
2647
3987
 
3988
+ // La page du compte n'est pas rafraîchie en continu : elle porte des champs en cours de saisie.
2648
3989
  setInterval(() => {
2649
- if (el.autoRefresh.checked && el.login.hidden) refresh({ keepThread: true });
3990
+ if (el.autoRefresh.checked && auth.loggedIn && state.view !== "account") refresh({ keepThread: true });
2650
3991
  }, REFRESH_MS);
2651
3992
 
2652
3993
  el.loginOrigin.textContent = `MCP Messenger · ${window.location.host}`;
2653
3994
  loadLinksWindow();
2654
3995
  renderEmptyReader();
2655
- refresh();
3996
+ updateSetupForm();
3997
+ checkSession();