@imenam/mcp-messenger 1.0.32 → 1.0.34

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,1204 @@ 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 textArea(value, placeholder) {
2699
+ const area = document.createElement("textarea");
2700
+ area.value = value || "";
2701
+ area.rows = 2;
2702
+ area.maxLength = 2000;
2703
+ if (placeholder) area.placeholder = placeholder;
2704
+ return area;
2705
+ }
2706
+
2707
+ function textInput(value, options = {}) {
2708
+ const input = document.createElement("input");
2709
+ input.type = options.type || "text";
2710
+ input.value = value || "";
2711
+ if (options.placeholder) input.placeholder = options.placeholder;
2712
+ if (options.autocomplete) input.autocomplete = options.autocomplete;
2713
+ if (options.list) input.setAttribute("list", options.list);
2714
+ input.spellcheck = false;
2715
+ return input;
2716
+ }
2717
+
2718
+ /** Bandeau d'erreur ou d'avertissement au-dessus du bouton d'un formulaire. */
2719
+ function showBanner(element, kind, message) {
2720
+ element.hidden = !message;
2721
+ element.className = `form-banner form-banner--${kind}`;
2722
+ element.replaceChildren();
2723
+ if (!message) return;
2724
+ element.appendChild(icon(kind === "warn" ? "timer" : "alertCircle"));
2725
+ element.appendChild(document.createTextNode(message));
2726
+ }
2727
+
2728
+ function formatWait(seconds) {
2729
+ if (seconds < 60) return `${seconds} s`;
2730
+ const minutes = Math.floor(seconds / 60);
2731
+ const rest = seconds % 60;
2732
+ return rest ? `${minutes} min ${String(rest).padStart(2, "0")} s` : `${minutes} min`;
2733
+ }
2734
+
2735
+ /** Ce qu'un refus de formulaire dit à l'utilisateur, selon le statut rendu. */
2736
+ async function formRefusal(response, messages) {
2737
+ if (!response) return "Serveur injoignable.";
2738
+ const payload = await response.json().catch(() => ({}));
2739
+ if (response.status === 429) {
2740
+ return `Trop de tentatives. Réessayez dans ${formatWait(payload.retry_after_seconds || 900)}.`;
2741
+ }
2742
+ return messages[response.status] || payload.error || response.statusText;
2743
+ }
2744
+
2745
+ /**
2746
+ * Robustesse d'un mot de passe, de 0 (vide) à 4. En deçà de la longueur minimale, il est
2747
+ * trop court quoi qu'il contienne ; au-delà, la variété des caractères et la longueur
2748
+ * comptent.
2749
+ */
2750
+ const PASSWORD_MIN = 12;
2751
+
2752
+ function passwordStrength(password) {
2753
+ if (!password) return { level: 0, label: `${PASSWORD_MIN} caractères minimum.` };
2754
+ const length = `${plural(password.length, "caractère")} (${PASSWORD_MIN} minimum)`;
2755
+ if (password.length < PASSWORD_MIN) return { level: 1, label: `Trop court · ${length}` };
2756
+ const classes = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((re) => re.test(password)).length;
2757
+ const score = Math.min(4, 1 + (classes >= 3 ? 1 : 0) + (password.length >= 16 ? 1 : 0) + (password.length >= 24 ? 1 : 0));
2758
+ const words = { 2: "Correct", 3: "Solide", 4: "Très solide" };
2759
+ return { level: Math.max(2, score), label: `${words[Math.max(2, score)]} · ${length}` };
2760
+ }
2761
+
2762
+ function renderMeter(meter, label, password) {
2763
+ const strength = passwordStrength(password);
2764
+ meter.dataset.level = String(strength.level);
2765
+ label.textContent = strength.label;
2766
+ label.classList.toggle("is-weak", strength.level === 1);
2767
+ label.classList.toggle("is-strong", strength.level >= 3);
2768
+ }
2769
+
2770
+ // --- Menu du compte --------------------------------------------------------
2771
+
2772
+ function closeAccountMenu() {
2773
+ el.accountMenu.hidden = true;
2774
+ el.accountButton.setAttribute("aria-expanded", "false");
2775
+ }
2776
+
2777
+ function toggleAccountMenu() {
2778
+ const open = el.accountMenu.hidden;
2779
+ el.accountMenu.hidden = !open;
2780
+ el.accountButton.setAttribute("aria-expanded", String(open));
2781
+ for (const item of el.accountMenu.querySelectorAll("[data-view]")) {
2782
+ item.classList.toggle("is-active", item.dataset.view === state.view);
2783
+ }
2784
+ }
2785
+
2786
+ // --- Clés API --------------------------------------------------------------
2787
+
2788
+ /**
2789
+ * Écran des clés : la liste, filtrée par état, et un volet qui sert à trois choses — créer
2790
+ * une clé, la montrer une fois créée, en voir le détail.
2791
+ */
2792
+ const keysView = {
2793
+ list: [],
2794
+ /** "active" | "expired" | "revoked" — la puce choisie au-dessus de la liste. */
2795
+ filter: "active",
2796
+ search: "",
2797
+ /** Clé dont le volet montre le détail ; null quand il est fermé ou sert à autre chose. */
2798
+ detailId: null,
2799
+ };
2800
+
2801
+ /** Durées proposées à la création et à la prolongation, en jours ; null = sans échéance. */
2802
+ const KEY_LIFETIMES = [
2803
+ { days: 30, label: "30 jours" },
2804
+ { days: 90, label: "90 jours" },
2805
+ { days: 365, label: "1 an" },
2806
+ { days: null, label: "Jamais" },
2807
+ ];
2808
+
2809
+ async function refreshKeys() {
2810
+ try {
2811
+ const [overview, keys] = await Promise.all([api("/admin/overview"), api("/admin/keys")]);
2812
+ setLoggedIn(true);
2813
+ setStatus("ok", `connecté · ${window.location.host}`);
2814
+ renderAgents(overview);
2815
+ keysView.list = keys.keys;
2816
+ renderKeys();
2817
+ } catch (error) {
2818
+ if (error.message === "unauthorized") {
2819
+ setStatus("idle", "authentification requise");
2820
+ return;
2821
+ }
2822
+ setStatus("ko", `erreur : ${error.message}`);
2823
+ }
2824
+ }
2825
+
2826
+ /** Noms d'agents connus : ceux des boîtes ouvertes et ceux des clés existantes. */
2827
+ function knownAgentNames() {
2828
+ const names = new Set();
2829
+ for (const box of state.directory.values()) if (box.agent_name) names.add(box.agent_name);
2830
+ for (const key of keysView.list) if (key.agent_name) names.add(key.agent_name);
2831
+ return [...names].sort((a, b) => a.localeCompare(b));
2832
+ }
2833
+
2834
+ function keyLabel(key) {
2835
+ return `${key.prefix}…${key.suffix}`;
2836
+ }
2837
+
2838
+ function agentDot(name) {
2839
+ return node("span", `key-dot avatar--c${colorIndex(name)}`);
2840
+ }
2841
+
2842
+ /** Agent d'une clé : sa pastille et son nom, ou « Tous les agents ». */
2843
+ function keyAgent(key) {
2844
+ const cell = node("span", "key-agent");
2845
+ if (key.agent_name) {
2846
+ cell.appendChild(agentDot(key.agent_name));
2847
+ cell.appendChild(node("span", null, key.agent_name));
2848
+ } else {
2849
+ cell.classList.add("is-muted");
2850
+ cell.appendChild(icon("users"));
2851
+ cell.appendChild(node("span", null, "Tous les agents"));
2852
+ }
2853
+ return cell;
2854
+ }
2855
+
2856
+ /** Échéance d'une clé, telle que la liste et le volet la montrent. */
2857
+ function keyExpiry(key) {
2858
+ if (key.status === "revoked") return node("span", "key-muted", `révoquée le ${formatDay(key.revoked_at)}`);
2859
+ if (key.status === "expired") return node("span", "key-muted", `expirée le ${formatDay(key.expires_at)}`);
2860
+ if (!key.expires_at) return node("span", "key-muted", "jamais");
2861
+ if (daysLeft(key.expires_at) <= EXPIRY_WARNING_DAYS) return tag("tag tag--warn", formatUntil(key.expires_at), "clock");
2862
+ return node("span", null, formatUntil(key.expires_at));
2863
+ }
2864
+
2865
+ function matchesKeySearch(key) {
2866
+ const search = keysView.search.toLowerCase();
2867
+ if (!search) return true;
2868
+ return [key.name, key.agent_name || "tous les agents", key.prefix, key.suffix].some((value) =>
2869
+ String(value).toLowerCase().includes(search)
2870
+ );
2871
+ }
2872
+
2873
+ function renderKeys() {
2874
+ const counts = { active: 0, expired: 0, revoked: 0 };
2875
+ for (const key of keysView.list) counts[key.status] += 1;
2876
+ for (const chip of el.keyFilters.querySelectorAll(".chip")) {
2877
+ chip.classList.toggle("is-active", chip.dataset.status === keysView.filter);
2878
+ chip.querySelector(".chip-count").textContent = String(counts[chip.dataset.status]);
2879
+ }
2880
+
2881
+ const shown = keysView.list.filter((key) => key.status === keysView.filter && matchesKeySearch(key));
2882
+ const head = node("div", "key-row key-row--head");
2883
+ for (const label of ["Nom", "Clé", "Agent autorisé", "Dernière utilisation", "Expiration", ""]) {
2884
+ head.appendChild(node("span", null, label));
2885
+ }
2886
+ const rows = shown.map(keyRow);
2887
+ if (rows.length === 0) {
2888
+ const empty = {
2889
+ active: "Aucune clé active. Créez-en une par poste ou par agent.",
2890
+ expired: "Aucune clé expirée.",
2891
+ revoked: "Aucune clé révoquée.",
2892
+ }[keysView.filter];
2893
+ rows.push(node("p", "key-empty", keysView.search ? "Aucune clé ne correspond à la recherche." : empty));
2894
+ }
2895
+ el.keyTable.replaceChildren(head, ...rows);
2896
+ }
2897
+
2898
+ function keyRow(key) {
2899
+ const row = node("div", "key-row");
2900
+ row.tabIndex = 0;
2901
+ row.setAttribute("role", "button");
2902
+ row.dataset.key = key.id;
2903
+ if (keysView.detailId === key.id && el.keyDrawer.open) row.classList.add("is-selected");
2904
+
2905
+ const name = node("span", "key-name");
2906
+ name.appendChild(node("strong", null, key.name));
2907
+ name.appendChild(node("span", "key-sub", `créée le ${formatDay(key.created_at)}`));
2908
+ row.appendChild(name);
2909
+ row.appendChild(node("span", "mono", keyLabel(key)));
2910
+ row.appendChild(keyAgent(key));
2911
+
2912
+ const used = node("span", "key-name");
2913
+ if (key.last_used_at) {
2914
+ const recent = Date.now() - new Date(key.last_used_at).getTime() < 3_600_000;
2915
+ used.appendChild(node("span", recent ? "key-recent" : null, formatAgo(key.last_used_at)));
2916
+ if (key.last_used_ip) used.appendChild(node("span", "key-sub", key.last_used_ip));
2917
+ } else {
2918
+ used.appendChild(node("span", "key-muted", "jamais utilisée"));
2919
+ }
2920
+ row.appendChild(used);
2921
+ row.appendChild(keyExpiry(key));
2922
+
2923
+ const actions = node("span", "key-actions");
2924
+ if (key.status === "active") {
2925
+ actions.appendChild(
2926
+ button("btn key-revoke", "Révoquer", (event) => {
2927
+ event.stopPropagation();
2928
+ openRevoke(key);
2929
+ })
2930
+ );
2931
+ }
2932
+ actions.appendChild(icon("chevron"));
2933
+ row.appendChild(actions);
2934
+
2935
+ row.addEventListener("click", () => openKeyDetail(key.id));
2936
+ row.addEventListener("keydown", (event) => {
2937
+ if (event.target !== row || (event.key !== "Enter" && event.key !== " ")) return;
2938
+ event.preventDefault();
2939
+ openKeyDetail(key.id);
2940
+ });
2941
+ return row;
2942
+ }
2943
+
2944
+ // Volet ----------------------------------------------------------------------
2945
+
2946
+ function openDrawer() {
2947
+ if (!el.keyDrawer.open) el.keyDrawer.showModal();
2948
+ }
2949
+
2950
+ function closeDrawer() {
2951
+ if (el.keyDrawer.open) el.keyDrawer.close();
2952
+ }
2953
+
2954
+ /** En-tête du volet : titre, sous-titre, bouton de fermeture. */
2955
+ function drawerHead(title, subtitle, titleNode) {
2956
+ const head = node("div", "drawer-head");
2957
+ const text = node("div", "drawer-title");
2958
+ text.appendChild(titleNode || node("h2", null, title));
2959
+ if (subtitle) text.appendChild(subtitle instanceof Node ? subtitle : node("p", "muted", subtitle));
2960
+ head.appendChild(text);
2961
+ const close = node("button", "btn btn--icon btn--ghost");
2962
+ close.type = "button";
2963
+ close.setAttribute("aria-label", "Fermer");
2964
+ close.appendChild(icon("close"));
2965
+ close.addEventListener("click", closeDrawer);
2966
+ head.appendChild(close);
2967
+ return head;
2968
+ }
2969
+
2970
+ function drawerFoot(...buttons) {
2971
+ const foot = node("div", "drawer-foot");
2972
+ for (const item of buttons) foot.appendChild(item);
2973
+ return foot;
2974
+ }
2975
+
2976
+ /** Choix parmi des puces : une seule retenue, celle que `selected` désigne. */
2977
+ function choiceChips(options, selected, onPick) {
2978
+ const group = node("div", "choice");
2979
+ for (const option of options) {
2980
+ const chip = button("choice-item", option.label, () => onPick(option));
2981
+ chip.classList.toggle("is-active", option === selected);
2982
+ chip.setAttribute("aria-pressed", String(option === selected));
2983
+ group.appendChild(chip);
2984
+ }
2985
+ return group;
2986
+ }
2987
+
2988
+ function openKeyCreate() {
2989
+ keysView.detailId = null;
2990
+ const form = { name: "", scope: "one", agent: "", description: "", lifetime: KEY_LIFETIMES[1] };
2991
+ const names = knownAgentNames();
2992
+
2993
+ const nameInput = textInput("", { placeholder: "Poste, machine ou usage", autocomplete: "off" });
2994
+ const agentInput = textInput("", { placeholder: "Nom de l'agent", autocomplete: "off", list: "key-agent-names" });
2995
+ const descriptionInput = textArea("", "À quoi sert cet agent, pour ses correspondants");
2996
+ descriptionInput.addEventListener("input", () => {
2997
+ form.description = descriptionInput.value;
2998
+ });
2999
+ const datalist = node("datalist");
3000
+ datalist.id = "key-agent-names";
3001
+ for (const name of names) {
3002
+ const option = document.createElement("option");
3003
+ option.value = name;
3004
+ datalist.appendChild(option);
3005
+ }
3006
+
3007
+ const agentMark = node("span", "key-dot");
3008
+ const newTag = node("span", "field-tag", "nouveau");
3009
+ function updateAgentMark() {
3010
+ const name = form.agent.trim();
3011
+ agentMark.className = `key-dot avatar--c${colorIndex(name || "?")}`;
3012
+ newTag.hidden = !name || names.includes(name);
3013
+ }
3014
+
3015
+ const error = node("p", "form-banner");
3016
+ error.hidden = true;
3017
+ const create = button("btn btn--primary", "Créer la clé", submit);
3018
+ const body = node("div", "drawer-content");
3019
+
3020
+ function render() {
3021
+ const agentField = node("div", "field field--tight");
3022
+ agentField.appendChild(node("span", "field-label", "Agent autorisé"));
3023
+ const scopes = [
3024
+ { id: "one", label: "Un seul agent" },
3025
+ { id: "all", label: "Tous les agents" },
3026
+ ];
3027
+ const segmented = node("div", "segmented");
3028
+ for (const scope of scopes) {
3029
+ const item = button("segmented-item", scope.label, () => {
3030
+ form.scope = scope.id;
3031
+ render();
3032
+ });
3033
+ item.classList.toggle("is-active", form.scope === scope.id);
3034
+ item.setAttribute("aria-pressed", String(form.scope === scope.id));
3035
+ segmented.appendChild(item);
3036
+ }
3037
+ agentField.appendChild(segmented);
3038
+ if (form.scope === "one") {
3039
+ const box = node("span", "field-input field-input--spaced");
3040
+ box.append(agentMark, agentInput, newTag);
3041
+ updateAgentMark();
3042
+ agentField.appendChild(box);
3043
+ const description = node("span", "field-input field-input--area");
3044
+ description.appendChild(descriptionInput);
3045
+ agentField.appendChild(description);
3046
+ agentField.appendChild(
3047
+ node(
3048
+ "span",
3049
+ "field-hint",
3050
+ "Le nom et la description s'appliquent à toutes les sessions ouvertes avec cette clé : rien à poser côté client."
3051
+ )
3052
+ );
3053
+ } else {
3054
+ agentField.appendChild(
3055
+ node(
3056
+ "span",
3057
+ "field-hint",
3058
+ "La clé ouvre les boîtes de tous les agents. Chaque client donne alors son nom d'agent dans MESSENGER_AGENT_NAME."
3059
+ )
3060
+ );
3061
+ }
3062
+
3063
+ const lifetimeField = node("div", "field field--tight");
3064
+ lifetimeField.appendChild(node("span", "field-label", "Expiration"));
3065
+ lifetimeField.appendChild(
3066
+ choiceChips(KEY_LIFETIMES, form.lifetime, (option) => {
3067
+ form.lifetime = option;
3068
+ render();
3069
+ })
3070
+ );
3071
+ lifetimeField.appendChild(
3072
+ node(
3073
+ "span",
3074
+ "field-hint",
3075
+ form.lifetime.days === null
3076
+ ? "La clé reste valable jusqu'à sa révocation."
3077
+ : `Expire le ${formatDay(new Date(Date.now() + form.lifetime.days * DAY_MS).toISOString())}. Signalée dans la liste ${EXPIRY_WARNING_DAYS} jours avant.`
3078
+ )
3079
+ );
3080
+
3081
+ const focused = document.activeElement;
3082
+ body.replaceChildren(
3083
+ formField("Nom", nameInput, "Pour la reconnaître dans la liste : le poste, la machine, l'usage."),
3084
+ agentField,
3085
+ lifetimeField,
3086
+ error,
3087
+ datalist
3088
+ );
3089
+ // Le volet se reconstruit à chaque choix : la saisie en cours garde le curseur.
3090
+ if (focused === agentInput || focused === nameInput || focused === descriptionInput) focused.focus();
3091
+ create.disabled = !form.name.trim() || (form.scope === "one" && !form.agent.trim());
3092
+ }
3093
+
3094
+ const updateCreate = () => {
3095
+ create.disabled = !form.name.trim() || (form.scope === "one" && !form.agent.trim());
3096
+ };
3097
+ nameInput.addEventListener("input", () => {
3098
+ form.name = nameInput.value;
3099
+ updateCreate();
3100
+ });
3101
+ // La saisie du nom d'agent ne reconstruit pas le volet : elle ne touche qu'à sa pastille
3102
+ // et à la mention « nouveau », sans quoi le champ perdrait le focus à chaque lettre.
3103
+ agentInput.addEventListener("input", () => {
3104
+ form.agent = agentInput.value;
3105
+ updateAgentMark();
3106
+ updateCreate();
3107
+ });
3108
+
3109
+ async function submit() {
3110
+ create.disabled = true;
3111
+ showBanner(error, "error", "");
3112
+ try {
3113
+ const created = await post("/admin/keys", {
3114
+ name: form.name.trim(),
3115
+ agent_name: form.scope === "one" ? form.agent.trim() : null,
3116
+ ...(form.scope === "one" && form.description.trim() ? { agent_description: form.description.trim() } : {}),
3117
+ lifetime_days: form.lifetime.days,
3118
+ });
3119
+ renderKeyReveal(created.key, created.secret, null);
3120
+ refreshKeys();
3121
+ } catch (failure) {
3122
+ showBanner(error, "error", failure.message);
3123
+ create.disabled = false;
3124
+ }
3125
+ }
3126
+
3127
+ const cancel = button("btn btn--link", "Annuler", closeDrawer);
3128
+ el.keyDrawerBody.replaceChildren(
3129
+ drawerHead("Nouvelle clé API", "Elle s'affichera une seule fois, juste après sa création."),
3130
+ body,
3131
+ drawerFoot(cancel, create)
3132
+ );
3133
+ render();
3134
+ openDrawer();
3135
+ nameInput.focus();
3136
+ }
3137
+
3138
+ /** Commande qui écrit l'entrée du client dans `.mcp.json`, clé et adresse comprises. */
3139
+ function setupCommand(secret) {
3140
+ return `npx -y @imenam/mcp-messenger --claude-setup-mcp --server-url ${window.location.origin} --api-key ${secret}`;
3141
+ }
3142
+
3143
+ function mcpJson(secret) {
3144
+ const entry = {
3145
+ mcpServers: {
3146
+ messenger: {
3147
+ command: "npx",
3148
+ args: ["-y", "@imenam/mcp-messenger"],
3149
+ env: { MESSENGER_SERVER_URL: window.location.origin, MESSENGER_API_KEY: secret },
3150
+ },
3151
+ },
3152
+ };
3153
+ // Le tableau des arguments tient sur une ligne, comme on l'écrit à la main.
3154
+ return JSON.stringify(entry, null, 2).replace(/\[\s+("-y"),\s+("[^"]+")\s+\]/, "[$1, $2]");
3155
+ }
3156
+
3157
+ /** Bouton « Copier » libellé : le geste principal de l'écran où la clé apparaît. */
3158
+ function copyLabelled(value, label) {
3159
+ const element = node("button", "btn btn--primary btn--small");
3160
+ element.type = "button";
3161
+ const render = (iconName, text) => element.replaceChildren(icon(iconName), document.createTextNode(text));
3162
+ render("copy", label);
3163
+ element.addEventListener("click", async () => {
3164
+ try {
3165
+ await navigator.clipboard.writeText(value);
3166
+ render("check", "Copiée");
3167
+ setTimeout(() => render("copy", label), 1500);
3168
+ } catch {
3169
+ // Presse-papiers refusé : la clé reste affichée à côté, sélectionnable.
3170
+ }
3171
+ });
3172
+ return element;
3173
+ }
3174
+
3175
+ /**
3176
+ * La clé en clair, une fois. Rien ne la garde : à la fermeture du volet, elle n'existe plus
3177
+ * que chez qui l'a copiée.
3178
+ */
3179
+ function renderKeyReveal(key, secret, replaced) {
3180
+ keysView.detailId = null;
3181
+ let tab = "json";
3182
+
3183
+ const summary = node("div", "notice notice--ok");
3184
+ summary.appendChild(icon("circleCheck"));
3185
+ const summaryText = node("div");
3186
+ summaryText.appendChild(node("strong", null, key.name));
3187
+ const scope = key.agent_name || "Tous les agents";
3188
+ const expiry = key.expires_at ? `expire le ${formatDay(key.expires_at)}` : "sans échéance";
3189
+ summaryText.appendChild(node("span", null, `${scope} · ${expiry}`));
3190
+ if (replaced) {
3191
+ summaryText.appendChild(
3192
+ node("span", null, `L'ancienne clé reste valable jusqu'au ${formatDate(replaced.expires_at)}.`)
3193
+ );
3194
+ }
3195
+ summary.appendChild(summaryText);
3196
+
3197
+ const keyField = node("div", "field field--tight");
3198
+ keyField.appendChild(node("span", "field-label", "Votre clé"));
3199
+ const keyBox = node("div", "secret");
3200
+ keyBox.appendChild(node("code", "secret-value", secret));
3201
+ keyBox.appendChild(copyLabelled(secret, "Copier"));
3202
+ keyField.appendChild(keyBox);
3203
+ const warning = node("p", "secret-warning");
3204
+ warning.appendChild(icon("alert"));
3205
+ warning.appendChild(
3206
+ document.createTextNode(
3207
+ "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."
3208
+ )
3209
+ );
3210
+ keyField.appendChild(warning);
3211
+
3212
+ const config = node("div", "field field--tight");
3213
+ config.appendChild(node("span", "field-label", "Configurer le client MCP"));
3214
+ const tabs = node("div", "subtabs");
3215
+ const code = node("div", "code-block");
3216
+ const hint = node("span", "field-hint");
3217
+
3218
+ function renderConfig() {
3219
+ tabs.replaceChildren(
3220
+ ...[
3221
+ { id: "json", label: ".mcp.json" },
3222
+ { id: "command", label: "Commande d'installation" },
3223
+ ].map((item) => {
3224
+ const tabButton = button("subtab", item.label, () => {
3225
+ tab = item.id;
3226
+ renderConfig();
3227
+ });
3228
+ tabButton.classList.toggle("is-active", tab === item.id);
3229
+ return tabButton;
3230
+ })
3231
+ );
3232
+ const text = tab === "json" ? mcpJson(secret) : setupCommand(secret);
3233
+ code.replaceChildren(node("pre", null, text), copyButton(text, "Copier"));
3234
+ hint.textContent =
3235
+ tab === "json"
3236
+ ? "La clé y figure en entier : le bloc copié est prêt à coller."
3237
+ : "À lancer dans le dossier du projet : elle écrit l'entrée messenger dans .mcp.json.";
3238
+ }
3239
+ renderConfig();
3240
+ config.appendChild(tabs);
3241
+ config.appendChild(code);
3242
+ config.appendChild(hint);
3243
+
3244
+ const body = node("div", "drawer-content");
3245
+ body.append(summary, keyField, config);
3246
+ el.keyDrawerBody.replaceChildren(
3247
+ drawerHead(replaced ? "Clé remplacée" : "Clé créée", "Copiez-la maintenant : elle ne sera plus jamais affichée."),
3248
+ body,
3249
+ drawerFoot(button("btn btn--primary", "J'ai copié la clé", closeDrawer))
3250
+ );
3251
+ openDrawer();
3252
+ }
3253
+
3254
+ async function openKeyDetail(id) {
3255
+ keysView.detailId = id;
3256
+ try {
3257
+ renderKeyDetail(await api(`/admin/keys/${encodeURIComponent(id)}`));
3258
+ openDrawer();
3259
+ renderKeys();
3260
+ } catch (error) {
3261
+ if (error.message !== "unauthorized") setStatus("ko", `erreur : ${error.message}`);
3262
+ }
3263
+ }
3264
+
3265
+ function renderKeyDetail({ key, mailboxes }) {
3266
+ let renaming = false;
3267
+ let describing = false;
3268
+ let extendOpen = false;
3269
+ const error = node("p", "form-banner");
3270
+ error.hidden = true;
3271
+
3272
+ const fail = (failure) => showBanner(error, "error", failure.message);
3273
+ const reload = async () => {
3274
+ await openKeyDetail(key.id);
3275
+ refreshKeys();
3276
+ };
3277
+
3278
+ function title() {
3279
+ const wrapper = node("div", "drawer-name");
3280
+ if (renaming) {
3281
+ const input = textInput(key.name, { autocomplete: "off" });
3282
+ input.className = "inline-input";
3283
+ // Entrée enregistre, puis la perte du focus qui suit voudrait enregistrer encore.
3284
+ let saved = false;
3285
+ const save = async () => {
3286
+ if (saved) return;
3287
+ saved = true;
3288
+ const name = input.value.trim();
3289
+ if (!name || name === key.name) {
3290
+ renaming = false;
3291
+ render();
3292
+ return;
3293
+ }
3294
+ try {
3295
+ await patch(`/admin/keys/${encodeURIComponent(key.id)}`, { name });
3296
+ await reload();
3297
+ } catch (failure) {
3298
+ saved = false;
3299
+ fail(failure);
3300
+ }
3301
+ };
3302
+ input.addEventListener("keydown", (event) => {
3303
+ if (event.key === "Enter") save();
3304
+ if (event.key === "Escape") {
3305
+ // Échap referme la saisie, pas le volet.
3306
+ event.preventDefault();
3307
+ event.stopPropagation();
3308
+ saved = true;
3309
+ renaming = false;
3310
+ render();
3311
+ }
3312
+ });
3313
+ input.addEventListener("blur", save);
3314
+ wrapper.appendChild(input);
3315
+ setTimeout(() => input.focus(), 0);
3316
+ return wrapper;
3317
+ }
3318
+ wrapper.appendChild(node("h2", null, key.name));
3319
+ const rename = node("button", "btn btn--icon btn--ghost");
3320
+ rename.type = "button";
3321
+ rename.title = "Renommer";
3322
+ rename.setAttribute("aria-label", "Renommer la clé");
3323
+ rename.appendChild(icon("pencil"));
3324
+ rename.addEventListener("click", () => {
3325
+ renaming = true;
3326
+ render();
3327
+ });
3328
+ wrapper.appendChild(rename);
3329
+ return wrapper;
3330
+ }
3331
+
3332
+ function states() {
3333
+ const row = node("div", "drawer-tags");
3334
+ const labels = { active: "active", expired: "expirée", revoked: "révoquée" };
3335
+ const status = node("span", `tag tag--${key.status === "active" ? "ok" : "muted"}`);
3336
+ if (key.status === "active") status.appendChild(node("span", "pill-dot"));
3337
+ status.appendChild(document.createTextNode(labels[key.status]));
3338
+ row.appendChild(status);
3339
+ if (key.status === "active" && key.expires_at && daysLeft(key.expires_at) <= EXPIRY_WARNING_DAYS) {
3340
+ row.appendChild(tag("tag tag--warn", `expire ${formatUntil(key.expires_at)}`, "clock"));
3341
+ }
3342
+ return row;
3343
+ }
3344
+
3345
+ function extendControl() {
3346
+ const wrapper = node("span", "split");
3347
+ const extend = async (days) => {
3348
+ try {
3349
+ await post(`/admin/keys/${encodeURIComponent(key.id)}/extend`, { days });
3350
+ await reload();
3351
+ } catch (failure) {
3352
+ fail(failure);
3353
+ }
3354
+ };
3355
+ wrapper.appendChild(button("btn btn--small split-main", "Prolonger de 90 jours", () => extend(90)));
3356
+ const more = node("button", "btn btn--small split-more");
3357
+ more.type = "button";
3358
+ more.setAttribute("aria-label", "Autres durées");
3359
+ more.setAttribute("aria-expanded", String(extendOpen));
3360
+ more.appendChild(icon("chevronDown"));
3361
+ more.addEventListener("click", () => {
3362
+ extendOpen = !extendOpen;
3363
+ render();
3364
+ });
3365
+ wrapper.appendChild(more);
3366
+ if (extendOpen) {
3367
+ const menu = node("div", "split-menu");
3368
+ for (const option of [
3369
+ { days: 30, label: "Prolonger de 30 jours" },
3370
+ { days: 365, label: "Prolonger d'un an" },
3371
+ { days: null, label: "Retirer l'échéance" },
3372
+ ]) {
3373
+ menu.appendChild(button("split-item", option.label, () => extend(option.days)));
3374
+ }
3375
+ wrapper.appendChild(menu);
3376
+ }
3377
+ return wrapper;
3378
+ }
3379
+
3380
+ function properties() {
3381
+ const list = node("dl", "props");
3382
+ const add = (label, value) => {
3383
+ list.appendChild(node("dt", null, label));
3384
+ const cell = node("dd");
3385
+ for (const part of [].concat(value)) cell.appendChild(typeof part === "string" ? document.createTextNode(part) : part);
3386
+ list.appendChild(cell);
3387
+ };
3388
+ add("Agent autorisé", keyAgent(key));
3389
+ if (key.agent_name) add("Description", descriptionCell());
3390
+ add("Créée", `${formatDay(key.created_at)}${key.created_by ? ` par ${key.created_by}` : ""}`);
3391
+ add(
3392
+ "Dernière utilisation",
3393
+ key.last_used_at ? `${formatAgo(key.last_used_at)}${key.last_used_ip ? ` · ${key.last_used_ip}` : ""}` : "jamais"
3394
+ );
3395
+ const expiry = key.expires_at ? formatDay(key.expires_at) : "sans échéance";
3396
+ add("Expiration", key.status === "revoked" ? [expiry] : [node("span", "props-value", expiry), extendControl()]);
3397
+ if (key.revoked_at) add("Révoquée", formatDate(key.revoked_at));
3398
+ return list;
3399
+ }
3400
+
3401
+ /** Description de l'agent, modifiable sur place : Entrée enregistre, Échap annule. */
3402
+ function descriptionCell() {
3403
+ if (!describing) {
3404
+ const value = node("span", key.agent_description ? "props-value" : "props-value key-muted", key.agent_description || "aucune");
3405
+ const edit = node("button", "btn btn--icon btn--ghost props-edit");
3406
+ edit.type = "button";
3407
+ edit.title = "Modifier la description";
3408
+ edit.setAttribute("aria-label", "Modifier la description de l'agent");
3409
+ edit.appendChild(icon("pencil"));
3410
+ edit.addEventListener("click", () => {
3411
+ describing = true;
3412
+ render();
3413
+ });
3414
+ return [value, edit];
3415
+ }
3416
+ const area = textArea(key.agent_description, "À quoi sert cet agent, pour ses correspondants");
3417
+ area.className = "inline-area";
3418
+ let saved = false;
3419
+ const save = async () => {
3420
+ if (saved) return;
3421
+ saved = true;
3422
+ const description = area.value.trim();
3423
+ if (description === (key.agent_description || "")) {
3424
+ describing = false;
3425
+ render();
3426
+ return;
3427
+ }
3428
+ try {
3429
+ await patch(`/admin/keys/${encodeURIComponent(key.id)}`, { agent_description: description || null });
3430
+ await reload();
3431
+ } catch (failure) {
3432
+ saved = false;
3433
+ fail(failure);
3434
+ }
3435
+ };
3436
+ area.addEventListener("keydown", (event) => {
3437
+ if (event.key === "Enter" && !event.shiftKey) {
3438
+ event.preventDefault();
3439
+ save();
3440
+ }
3441
+ if (event.key === "Escape") {
3442
+ // Échap referme la saisie, pas le volet.
3443
+ event.preventDefault();
3444
+ event.stopPropagation();
3445
+ saved = true;
3446
+ describing = false;
3447
+ render();
3448
+ }
3449
+ });
3450
+ area.addEventListener("blur", save);
3451
+ setTimeout(() => area.focus(), 0);
3452
+ return [area];
3453
+ }
3454
+
3455
+ function boxes() {
3456
+ const section = node("section", "drawer-section");
3457
+ const head = node("div", "drawer-section-head");
3458
+ head.appendChild(node("h3", null, "Boîtes ouvertes avec cette clé"));
3459
+ head.appendChild(node("span", "muted", String(mailboxes.length)));
3460
+ section.appendChild(head);
3461
+ if (mailboxes.length === 0) {
3462
+ section.appendChild(node("p", "key-muted", "Aucune boîte n'a encore été ouverte avec cette clé."));
3463
+ return section;
3464
+ }
3465
+ const list = node("ul", "key-boxes");
3466
+ for (const box of mailboxes) {
3467
+ const item = node("li");
3468
+ const line = button("key-box", "", () => {
3469
+ closeDrawer();
3470
+ state.selection = { kind: "box", key: box.id };
3471
+ state.threadId = null;
3472
+ setView("threads");
3473
+ });
3474
+ line.title = "Voir ses fils";
3475
+ line.appendChild(avatarFor(box.agent_name || box.id, box.display_name || box.agent_name || box.id, box.id));
3476
+ const text = node("span", "key-name");
3477
+ text.appendChild(node("strong", null, box.display_name || "boîte sans nom"));
3478
+ const activity = box.last_activity_at ? `activité ${formatShort(box.last_activity_at)}` : "aucune activité";
3479
+ text.appendChild(node("span", "key-sub", `${box.id} · ${activity}`));
3480
+ line.appendChild(text);
3481
+ line.appendChild(icon("arrowUpRight"));
3482
+ item.appendChild(line);
3483
+ list.appendChild(item);
3484
+ }
3485
+ section.appendChild(list);
3486
+ return section;
3487
+ }
3488
+
3489
+ function rotate() {
3490
+ const section = node("section", "drawer-section drawer-row");
3491
+ const text = node("div");
3492
+ text.appendChild(node("h3", null, "Remplacer la clé"));
3493
+ text.appendChild(
3494
+ node("p", "key-muted", "Nouvelle clé, mêmes réglages. L'ancienne reste valable 24 h, le temps de mettre le client à jour.")
3495
+ );
3496
+ section.appendChild(text);
3497
+ const action = node("button", "btn");
3498
+ action.type = "button";
3499
+ action.append(icon("refresh"), document.createTextNode("Remplacer"));
3500
+ action.addEventListener("click", async () => {
3501
+ action.disabled = true;
3502
+ try {
3503
+ const rotated = await post(`/admin/keys/${encodeURIComponent(key.id)}/rotate`, {});
3504
+ renderKeyReveal(rotated.key, rotated.secret, rotated.replaced);
3505
+ refreshKeys();
3506
+ } catch (failure) {
3507
+ action.disabled = false;
3508
+ fail(failure);
3509
+ }
3510
+ });
3511
+ section.appendChild(action);
3512
+ return section;
3513
+ }
3514
+
3515
+ function danger() {
3516
+ const section = node("section", "danger-zone");
3517
+ const text = node("div");
3518
+ text.appendChild(node("h3", null, "Révoquer la clé"));
3519
+ text.appendChild(node("p", null, "Refusée dès l'appel suivant. Définitif."));
3520
+ section.appendChild(text);
3521
+ section.appendChild(button("btn btn--danger", "Révoquer…", () => openRevoke(key, mailboxes.length)));
3522
+ return section;
3523
+ }
3524
+
3525
+ function render() {
3526
+ const subtitle = node("p", "mono muted", keyLabel(key));
3527
+ const body = node("div", "drawer-content");
3528
+ body.append(states(), properties(), boxes());
3529
+ if (key.status === "active") body.appendChild(rotate());
3530
+ if (key.status !== "revoked") body.appendChild(danger());
3531
+ body.appendChild(error);
3532
+ el.keyDrawerBody.replaceChildren(drawerHead(null, subtitle, title()), body);
3533
+ }
3534
+
3535
+ render();
3536
+ }
3537
+
3538
+ /** Confirmation de révocation : ce qui s'arrête, ce qui reste, et que c'est définitif. */
3539
+ function openRevoke(key, mailboxCount = key.mailboxes) {
3540
+ const card = el.keyRevokeBody;
3541
+ const head = node("div", "confirm-head");
3542
+ const badge = node("span", "confirm-badge");
3543
+ badge.appendChild(icon("ban"));
3544
+ head.appendChild(badge);
3545
+ const title = node("div");
3546
+ title.appendChild(node("h2", null, `Révoquer « ${key.name} » ?`));
3547
+ title.appendChild(node("p", "mono muted", `${keyLabel(key)} · ${key.agent_name || "Tous les agents"}`));
3548
+ head.appendChild(title);
3549
+
3550
+ const consequences = node("ul", "confirm-list");
3551
+ const lost = node("li", "is-lost");
3552
+ lost.appendChild(icon("close"));
3553
+ lost.appendChild(
3554
+ document.createTextNode(
3555
+ mailboxCount > 0
3556
+ ? `${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.`
3557
+ : "Les sessions qui l'utilisent perdent l'accès dès leur prochain appel."
3558
+ )
3559
+ );
3560
+ const kept = node("li", "is-kept");
3561
+ kept.appendChild(icon("check"));
3562
+ kept.appendChild(document.createTextNode("Leurs messages restent lisibles dans Fils."));
3563
+ consequences.append(lost, kept);
3564
+
3565
+ const error = node("p", "form-banner");
3566
+ error.hidden = true;
3567
+ const confirm = button("btn btn--danger", "Révoquer la clé", async () => {
3568
+ confirm.disabled = true;
3569
+ try {
3570
+ await post(`/admin/keys/${encodeURIComponent(key.id)}/revoke`, {});
3571
+ el.keyRevoke.close();
3572
+ if (el.keyDrawer.open && keysView.detailId === key.id) await openKeyDetail(key.id);
3573
+ await refreshKeys();
3574
+ } catch (failure) {
3575
+ confirm.disabled = false;
3576
+ showBanner(error, "error", failure.message);
3577
+ }
3578
+ });
3579
+ const foot = node("div", "modal-foot");
3580
+ foot.append(button("btn btn--link", "Annuler", () => el.keyRevoke.close()), confirm);
3581
+
3582
+ card.replaceChildren(head, consequences, node("p", "confirm-note", "Une clé révoquée ne se réactive pas."), error, foot);
3583
+ el.keyRevoke.showModal();
3584
+ confirm.focus();
3585
+ }
3586
+
3587
+ // --- Mon compte ------------------------------------------------------------
3588
+
3589
+ /** Navigateur et système lus dans l'en-tête `User-Agent`, assez pour reconnaître un appareil. */
3590
+ function describeDevice(userAgent) {
3591
+ const ua = String(userAgent || "");
3592
+ const browser = /Edg\//.test(ua)
3593
+ ? "Edge"
3594
+ : /OPR\//.test(ua)
3595
+ ? "Opera"
3596
+ : /Firefox\//.test(ua)
3597
+ ? "Firefox"
3598
+ : /Chrome\//.test(ua)
3599
+ ? "Chrome"
3600
+ : /Safari\//.test(ua)
3601
+ ? "Safari"
3602
+ : "Navigateur";
3603
+ const system = /Android/.test(ua)
3604
+ ? "Android"
3605
+ : /iPhone|iPad/.test(ua)
3606
+ ? "iOS"
3607
+ : /Windows/.test(ua)
3608
+ ? "Windows"
3609
+ : /Mac OS X/.test(ua)
3610
+ ? "macOS"
3611
+ : /Linux/.test(ua)
3612
+ ? "Linux"
3613
+ : null;
3614
+ return {
3615
+ label: system ? `${browser} · ${system}` : browser,
3616
+ mobile: /Mobi|Android|iPhone|iPad/.test(ua),
3617
+ };
3618
+ }
3619
+
3620
+ const accountView = {
3621
+ data: null,
3622
+ renaming: false,
3623
+ /** Résultat de la dernière action, montré sous la carte concernée. */
3624
+ notice: { identity: null, password: null, sessions: null },
3625
+ };
3626
+
3627
+ async function refreshAccount() {
3628
+ try {
3629
+ const data = await api("/admin/account");
3630
+ setLoggedIn(true);
3631
+ setStatus("ok", `connecté · ${window.location.host}`);
3632
+ accountView.data = data;
3633
+ setAccountUser(data.user);
3634
+ renderAccount();
3635
+ } catch (error) {
3636
+ if (error.message === "unauthorized") {
3637
+ setStatus("idle", "authentification requise");
3638
+ return;
3639
+ }
3640
+ setStatus("ko", `erreur : ${error.message}`);
3641
+ }
3642
+ }
3643
+
3644
+ function accountNotice(slot) {
3645
+ const notice = accountView.notice[slot];
3646
+ const element = node("p", "form-banner");
3647
+ if (!notice) {
3648
+ element.hidden = true;
3649
+ return element;
3650
+ }
3651
+ if (notice.kind === "ok") {
3652
+ element.className = "form-banner form-banner--ok";
3653
+ element.append(icon("circleCheck"), document.createTextNode(notice.message));
3654
+ return element;
3655
+ }
3656
+ showBanner(element, notice.kind, notice.message);
3657
+ return element;
3658
+ }
3659
+
3660
+ function renderAccount() {
3661
+ const { user, sessions } = accountView.data;
3662
+ el.accountBody.replaceChildren(identityCard(user), passwordCard(), sessionsCard(sessions));
3663
+ }
3664
+
3665
+ function identityCard(user) {
3666
+ const card = node("section", "panel-card panel-card--row");
3667
+ card.appendChild(node("span", "account-avatar account-avatar--lg", initials(user.username).slice(0, 1)));
3668
+ const text = node("div", "panel-card-main");
3669
+ if (accountView.renaming) {
3670
+ const input = textInput(user.username, { autocomplete: "username" });
3671
+ input.className = "inline-input";
3672
+ const save = async () => {
3673
+ const username = input.value.trim();
3674
+ if (!username || username === user.username) {
3675
+ accountView.renaming = false;
3676
+ renderAccount();
3677
+ return;
3678
+ }
3679
+ try {
3680
+ await patch("/admin/account", { username });
3681
+ accountView.renaming = false;
3682
+ accountView.notice.identity = { kind: "ok", message: `Identifiant changé : ${username}.` };
3683
+ await refreshAccount();
3684
+ } catch (failure) {
3685
+ accountView.notice.identity = {
3686
+ kind: "error",
3687
+ message: failure.status === 409 ? "Cet identifiant est déjà pris." : failure.message,
3688
+ };
3689
+ renderAccount();
3690
+ }
3691
+ };
3692
+ input.addEventListener("keydown", (event) => {
3693
+ if (event.key === "Enter") save();
3694
+ if (event.key === "Escape") {
3695
+ accountView.renaming = false;
3696
+ renderAccount();
3697
+ }
3698
+ });
3699
+ text.appendChild(input);
3700
+ setTimeout(() => input.focus(), 0);
3701
+ } else {
3702
+ text.appendChild(node("strong", "panel-card-title", user.username));
3703
+ }
3704
+ text.appendChild(node("span", "key-sub", `Administrateur · compte créé le ${formatDay(user.created_at)}`));
3705
+ text.appendChild(accountNotice("identity"));
3706
+ card.appendChild(text);
3707
+ if (!accountView.renaming) {
3708
+ card.appendChild(
3709
+ button("btn btn--link", "Changer l'identifiant", () => {
3710
+ accountView.renaming = true;
3711
+ accountView.notice.identity = null;
3712
+ renderAccount();
3713
+ })
3714
+ );
3715
+ }
3716
+ return card;
3717
+ }
3718
+
3719
+ function passwordCard() {
3720
+ const card = node("section", "panel-card");
3721
+ card.appendChild(node("h3", null, "Mot de passe"));
3722
+ card.appendChild(
3723
+ node(
3724
+ "p",
3725
+ "key-muted",
3726
+ "Le changer déconnecte toutes les autres sessions de la console. Les clés API ne sont pas touchées."
3727
+ )
3728
+ );
3729
+
3730
+ const current = textInput("", { type: "password", autocomplete: "current-password" });
3731
+ const next = textInput("", { type: "password", autocomplete: "new-password" });
3732
+ const confirm = textInput("", { type: "password", autocomplete: "new-password" });
3733
+ const meter = node("span", "meter");
3734
+ meter.dataset.level = "0";
3735
+ for (let i = 0; i < 4; i += 1) meter.appendChild(node("span"));
3736
+ const meterLabel = node("span", "field-hint", `${PASSWORD_MIN} caractères minimum.`);
3737
+
3738
+ const nextField = formField("Nouveau mot de passe", next);
3739
+ nextField.append(meter, meterLabel);
3740
+ const grid = node("div", "form-grid");
3741
+ const currentField = formField("Mot de passe actuel", current);
3742
+ currentField.classList.add("form-grid-full");
3743
+ grid.append(currentField, nextField, formField("Confirmer", confirm));
3744
+
3745
+ const submit = button("btn btn--primary", "Changer le mot de passe", async () => {
3746
+ submit.disabled = true;
3747
+ try {
3748
+ const result = await post("/admin/account/password", {
3749
+ current_password: current.value,
3750
+ new_password: next.value,
3751
+ });
3752
+ accountView.notice.password = {
3753
+ kind: "ok",
3754
+ message:
3755
+ result.closed_sessions > 0
3756
+ ? `Mot de passe changé · ${plural(result.closed_sessions, "autre session fermée", "autres sessions fermées")}.`
3757
+ : "Mot de passe changé.",
3758
+ };
3759
+ await refreshAccount();
3760
+ } catch (failure) {
3761
+ accountView.notice.password = {
3762
+ kind: failure.status === 429 ? "warn" : "error",
3763
+ message:
3764
+ failure.status === 403
3765
+ ? "Mot de passe actuel incorrect."
3766
+ : failure.status === 429
3767
+ ? "Trop de tentatives. Réessayez dans quelques minutes."
3768
+ : failure.message,
3769
+ };
3770
+ renderAccount();
3771
+ }
3772
+ });
3773
+ const update = () => {
3774
+ renderMeter(meter, meterLabel, next.value);
3775
+ submit.disabled = !current.value || next.value.length < PASSWORD_MIN || next.value !== confirm.value;
3776
+ };
3777
+ for (const input of [current, next, confirm]) input.addEventListener("input", update);
3778
+ update();
3779
+
3780
+ const foot = node("div", "panel-card-foot");
3781
+ foot.appendChild(submit);
3782
+ card.append(grid, accountNotice("password"), foot);
3783
+ return card;
3784
+ }
3785
+
3786
+ function sessionsCard(sessions) {
3787
+ const card = node("section", "panel-card panel-card--flush");
3788
+ const head = node("div", "panel-card-head");
3789
+ head.appendChild(node("h3", null, "Sessions de la console"));
3790
+ head.appendChild(node("p", "key-muted", "Les navigateurs connectés avec votre mot de passe."));
3791
+ card.appendChild(head);
3792
+
3793
+ const list = node("ul", "session-list");
3794
+ for (const session of sessions) {
3795
+ const device = describeDevice(session.user_agent);
3796
+ const item = node("li", "session");
3797
+ item.appendChild(icon(device.mobile ? "smartphone" : "monitor"));
3798
+ const text = node("div", "key-name");
3799
+ text.appendChild(node("strong", null, device.label));
3800
+ const detail = session.current
3801
+ ? `connecté depuis le ${formatDay(session.created_at)} · expire le ${formatDay(session.expires_at)}`
3802
+ : `vue ${formatAgo(session.last_seen_at)}`;
3803
+ text.appendChild(node("span", "key-sub", [session.ip, detail].filter(Boolean).join(" · ")));
3804
+ item.appendChild(text);
3805
+ if (session.current) {
3806
+ item.appendChild(node("span", "tag tag--ok", "cet appareil"));
3807
+ } else {
3808
+ item.appendChild(
3809
+ button("btn btn--small btn--danger-text", "Déconnecter", async () => {
3810
+ try {
3811
+ await remove(`/admin/account/sessions/${encodeURIComponent(session.id)}`);
3812
+ accountView.notice.sessions = { kind: "ok", message: `${device.label} déconnecté.` };
3813
+ await refreshAccount();
3814
+ } catch (failure) {
3815
+ accountView.notice.sessions = { kind: "error", message: failure.message };
3816
+ renderAccount();
3817
+ }
3818
+ })
3819
+ );
3820
+ }
3821
+ list.appendChild(item);
3822
+ }
3823
+ card.appendChild(list);
3824
+
3825
+ const foot = node("div", "panel-card-foot panel-card-foot--start");
3826
+ if (sessions.some((session) => !session.current)) {
3827
+ foot.appendChild(
3828
+ button("btn btn--link btn--danger-text", "Déconnecter toutes les autres sessions", async () => {
3829
+ try {
3830
+ const result = await post("/admin/account/sessions/revoke-others", {});
3831
+ accountView.notice.sessions = {
3832
+ kind: "ok",
3833
+ message: `${plural(result.closed_sessions, "session fermée", "sessions fermées")}.`,
3834
+ };
3835
+ await refreshAccount();
3836
+ } catch (failure) {
3837
+ accountView.notice.sessions = { kind: "error", message: failure.message };
3838
+ renderAccount();
3839
+ }
3840
+ })
3841
+ );
3842
+ }
3843
+ foot.appendChild(accountNotice("sessions"));
3844
+ card.appendChild(foot);
3845
+ return card;
3846
+ }
3847
+
2535
3848
  // --- Écouteurs -------------------------------------------------------------
2536
3849
 
2537
3850
  el.purgeOpen.addEventListener("click", openPurge);
@@ -2592,41 +3905,141 @@ el.search.addEventListener("input", (event) => {
2592
3905
  searchTimer = setTimeout(() => refresh({ keepThread: true }), 250);
2593
3906
  });
2594
3907
 
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
- });
3908
+ // L'œil d'un champ de mot de passe le montre ou le masque, sans quitter le champ.
3909
+ for (const reveal of document.querySelectorAll("[data-reveal]")) {
3910
+ reveal.addEventListener("click", () => {
3911
+ const input = document.getElementById(reveal.dataset.reveal);
3912
+ const shown = input.type === "text";
3913
+ input.type = shown ? "password" : "text";
3914
+ reveal.setAttribute("aria-label", shown ? "Afficher le mot de passe" : "Masquer le mot de passe");
3915
+ input.focus();
3916
+ });
3917
+ }
2601
3918
 
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", {
3919
+ function postForm(path, body) {
3920
+ return fetch(path, {
2609
3921
  method: "POST",
2610
- headers: { "content-type": "application/json" },
3922
+ headers: { "content-type": "application/json", accept: "application/json" },
2611
3923
  credentials: "same-origin",
2612
- body: JSON.stringify({ token }),
3924
+ body: JSON.stringify(body),
2613
3925
  }).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";
3926
+ }
3927
+
3928
+ el.loginForm.addEventListener("submit", async (event) => {
3929
+ event.preventDefault();
3930
+ showBanner(el.loginError, "error", "");
3931
+ const username = el.loginUsername.value.trim();
3932
+ const password = el.loginPassword.value;
3933
+ if (!username || !password) return;
3934
+ const response = await postForm("/ui/login", { username, password, remember: el.loginRemember.checked });
3935
+ // Le mot de passe ne reste pas dans la page, qu'il ait été accepté ou non.
3936
+ el.loginPassword.value = "";
3937
+ el.loginPassword.type = "password";
2617
3938
  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.";
3939
+ const message = await formRefusal(response, { 401: "Nom d'utilisateur ou mot de passe incorrect." });
3940
+ showBanner(el.loginError, response && response.status === 429 ? "warn" : "error", message);
3941
+ el.loginPassword.focus();
2623
3942
  return;
2624
3943
  }
2625
- refresh();
3944
+ await checkSession();
3945
+ });
3946
+
3947
+ function updateSetupForm() {
3948
+ renderMeter(el.setupMeter, el.setupMeterLabel, el.setupPassword.value);
3949
+ el.setupSubmit.disabled =
3950
+ !el.setupCode.value.trim() ||
3951
+ !el.setupUsername.value.trim() ||
3952
+ el.setupPassword.value.length < PASSWORD_MIN ||
3953
+ el.setupPassword.value !== el.setupConfirm.value;
3954
+ }
3955
+
3956
+ for (const input of [el.setupCode, el.setupUsername, el.setupPassword, el.setupConfirm]) {
3957
+ input.addEventListener("input", updateSetupForm);
3958
+ }
3959
+
3960
+ el.setupForm.addEventListener("submit", async (event) => {
3961
+ event.preventDefault();
3962
+ showBanner(el.setupError, "error", "");
3963
+ if (el.setupSubmit.disabled) return;
3964
+ const response = await postForm("/ui/setup", {
3965
+ code: el.setupCode.value.trim(),
3966
+ username: el.setupUsername.value.trim(),
3967
+ password: el.setupPassword.value,
3968
+ });
3969
+ if (!response || !response.ok) {
3970
+ // Le compte a été créé entre-temps, ailleurs : il ne reste qu'à s'y connecter.
3971
+ if (response && response.status === 409) {
3972
+ auth.setupRequired = false;
3973
+ setLoggedIn(false);
3974
+ return;
3975
+ }
3976
+ const message = await formRefusal(response, {
3977
+ 400: "Vérifiez les champs : un identifiant sans espace, un mot de passe de 12 caractères au moins.",
3978
+ 403: "Code d'installation incorrect. Recopiez celui du journal du serveur.",
3979
+ });
3980
+ showBanner(el.setupError, response && response.status === 429 ? "warn" : "error", message);
3981
+ return;
3982
+ }
3983
+ el.setupPassword.value = "";
3984
+ el.setupConfirm.value = "";
3985
+ auth.setupRequired = false;
3986
+ await checkSession();
3987
+ });
3988
+
3989
+ el.accountButton.addEventListener("click", (event) => {
3990
+ event.stopPropagation();
3991
+ toggleAccountMenu();
3992
+ });
3993
+
3994
+ el.accountMenu.addEventListener("click", (event) => {
3995
+ const item = event.target.closest("[data-view]");
3996
+ if (item) setView(item.dataset.view);
3997
+ });
3998
+
3999
+ document.addEventListener("click", (event) => {
4000
+ if (!el.accountMenu.hidden && !el.account.contains(event.target)) closeAccountMenu();
4001
+ });
4002
+
4003
+ document.addEventListener("keydown", (event) => {
4004
+ if (event.key === "Escape" && !el.accountMenu.hidden) {
4005
+ closeAccountMenu();
4006
+ el.accountButton.focus();
4007
+ }
4008
+ });
4009
+
4010
+ el.keyNew.addEventListener("click", openKeyCreate);
4011
+
4012
+ el.keyFilters.addEventListener("click", (event) => {
4013
+ const chip = event.target.closest(".chip");
4014
+ if (!chip) return;
4015
+ keysView.filter = chip.dataset.status;
4016
+ renderKeys();
4017
+ });
4018
+
4019
+ el.keySearch.addEventListener("input", () => {
4020
+ keysView.search = el.keySearch.value.trim();
4021
+ renderKeys();
4022
+ });
4023
+
4024
+ el.keyDrawer.addEventListener("close", () => {
4025
+ // La clé montrée une fois disparaît avec le volet : rien n'en reste dans la page.
4026
+ el.keyDrawerBody.replaceChildren();
4027
+ keysView.detailId = null;
4028
+ if (state.view === "keys") renderKeys();
4029
+ });
4030
+
4031
+ // Un clic sur le voile, hors du volet, le referme.
4032
+ el.keyDrawer.addEventListener("click", (event) => {
4033
+ if (event.target === el.keyDrawer) closeDrawer();
2626
4034
  });
2627
4035
 
2628
4036
  el.logout.addEventListener("click", async () => {
2629
4037
  await fetch("/ui/logout", { method: "POST", credentials: "same-origin" }).catch(() => null);
4038
+ setAccountUser(null);
4039
+ keysView.list = [];
4040
+ accountView.data = null;
4041
+ el.keyTable.replaceChildren();
4042
+ el.accountBody.replaceChildren();
2630
4043
  state.selection = { kind: "all", key: null };
2631
4044
  state.expanded = new Set();
2632
4045
  state.showEmpty = new Set();
@@ -2645,11 +4058,13 @@ el.logout.addEventListener("click", async () => {
2645
4058
  setLoggedIn(false);
2646
4059
  });
2647
4060
 
4061
+ // La page du compte n'est pas rafraîchie en continu : elle porte des champs en cours de saisie.
2648
4062
  setInterval(() => {
2649
- if (el.autoRefresh.checked && el.login.hidden) refresh({ keepThread: true });
4063
+ if (el.autoRefresh.checked && auth.loggedIn && state.view !== "account") refresh({ keepThread: true });
2650
4064
  }, REFRESH_MS);
2651
4065
 
2652
4066
  el.loginOrigin.textContent = `MCP Messenger · ${window.location.host}`;
2653
4067
  loadLinksWindow();
2654
4068
  renderEmptyReader();
2655
- refresh();
4069
+ updateSetupForm();
4070
+ checkSession();