@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.
- package/.env.example +12 -22
- package/README.md +825 -772
- package/dist/src/cli/reset-password.d.ts +12 -0
- package/dist/src/cli/reset-password.js +29 -0
- package/dist/src/cli/setup-mcp.d.ts +5 -1
- package/dist/src/cli/setup-mcp.js +15 -37
- package/dist/src/cli/watch-handoff.d.ts +3 -3
- package/dist/src/cli/watch-handoff.js +2 -2
- package/dist/src/cli/watch.d.ts +1 -1
- package/dist/src/cli/watch.js +3 -3
- package/dist/src/client/messenger-client.d.ts +3 -3
- package/dist/src/client/messenger-client.js +5 -6
- package/dist/src/config.d.ts +13 -8
- package/dist/src/config.js +32 -43
- package/dist/src/core/access-store.d.ts +124 -0
- package/dist/src/core/access-store.js +307 -0
- package/dist/src/core/credentials.d.ts +28 -0
- package/dist/src/core/credentials.js +75 -0
- package/dist/src/core/errors.d.ts +5 -0
- package/dist/src/core/errors.js +9 -0
- package/dist/src/core/store.d.ts +5 -5
- package/dist/src/core/store.js +8 -9
- package/dist/src/index.js +32 -17
- package/dist/src/mcp/start.js +26 -26
- package/dist/src/mcp/tools.js +2 -2
- package/dist/src/server/app.d.ts +10 -1
- package/dist/src/server/app.js +247 -69
- package/dist/src/server/console-session.d.ts +31 -21
- package/dist/src/server/console-session.js +46 -63
- package/dist/src/server/start.d.ts +3 -0
- package/dist/src/server/start.js +10 -13
- package/dist/src/server/ui/app.js +1380 -38
- package/dist/src/server/ui/index.html +192 -9
- package/dist/src/server/ui/styles.css +1144 -0
- package/package.json +2 -3
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
398
|
-
el.
|
|
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
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
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
|
-
|
|
2603
|
-
|
|
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(
|
|
3851
|
+
body: JSON.stringify(body),
|
|
2613
3852
|
}).catch(() => null);
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
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
|
-
|
|
2619
|
-
el.
|
|
2620
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
3996
|
+
updateSetupForm();
|
|
3997
|
+
checkSession();
|