@imenam/mcp-messenger 1.0.5 → 1.0.6

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/README.md CHANGED
@@ -43,7 +43,7 @@ npx @imenam/mcp-messenger --generate-token
43
43
  MESSENGER_TOKEN=<le-jeton> npx @imenam/mcp-messenger --server
44
44
  # → listening on http://127.0.0.1:3600 (loopback only)
45
45
  # → auth: shared token — required on every request
46
- # → web console: http://127.0.0.1:3600/ui/?token=<MESSENGER_ADMIN_TOKEN>
46
+ # → web console: http://127.0.0.1:3600/ui/ (asks for MESSENGER_ADMIN_TOKEN once)
47
47
  ```
48
48
 
49
49
  Deux invariants, non négociables et vérifiés par les tests :
@@ -51,9 +51,10 @@ Deux invariants, non négociables et vérifiés par les tests :
51
51
  1. **Le serveur écoute toujours sur `127.0.0.1`.** `MESSENGER_HOST` n'est pas un réglage :
52
52
  toute autre valeur fait échouer le démarrage. Pour joindre la boîte depuis une autre
53
53
  machine, il faut placer un reverse proxy (TLS) devant.
54
- 2. **Un jeton est obligatoire et vérifié sur chaque requête entrante** — API, `/health` et
55
- console web comprises. Sans `MESSENGER_TOKEN` ni `MESSENGER_AGENT_TOKENS`, le serveur
56
- refuse de démarrer.
54
+ 2. **Un jeton est obligatoire et vérifié sur chaque requête de données** — API, `/health`
55
+ et données de la console (`/admin`) comprises. Seule la page de la console, qui ne
56
+ contient aucun secret, est servie librement. Sans `MESSENGER_TOKEN` ni
57
+ `MESSENGER_AGENT_TOKENS`, le serveur refuse de démarrer.
57
58
 
58
59
  ### 2. Déclarer le MCP côté agent
59
60
 
@@ -273,8 +274,7 @@ configuration partagent alors la même boîte. Utile pour un service unique et s
273
274
 
274
275
  ## Console web
275
276
 
276
- `http://127.0.0.1:3600/ui/?token=<MESSENGER_ADMIN_TOKEN>` affiche, en lecture seule,
277
- **tous les échanges** de la flotte :
277
+ `http://127.0.0.1:3600/ui/` affiche, en lecture seule, **tous les échanges** de la flotte :
278
278
 
279
279
  - la liste des agents avec leurs compteurs (envoyés, reçus, non lus) ;
280
280
  - tous les messages, filtrables par agent, par fil ou par recherche texte ;
@@ -282,14 +282,18 @@ configuration partagent alors la même boîte. Utile pour un service unique et s
282
282
  - le fil complet d'une conversation ;
283
283
  - un rafraîchissement automatique toutes les 5 secondes.
284
284
 
285
- Un navigateur ne peut pas poser d'en-tête sur une navigation : le jeton passe donc par
286
- `?token=` dans l'URL, et le serveur le propage aux ressources de la page (`styles.css`,
287
- `app.js`), elles aussi protégées. Ouvrir `/ui/` sans jeton renvoie un `401`. En dernier
288
- recours, la console propose un champ pour saisir le jeton et le conserve dans le
289
- `localStorage` du navigateur.
285
+ À l'ouverture, la console demande `MESSENGER_ADMIN_TOKEN` dans un formulaire. Le serveur
286
+ le vérifie (`POST /ui/login`) et ouvre une **session** de 12 heures, portée par un cookie
287
+ `HttpOnly` + `SameSite=Strict` (+ `Secure` en HTTPS). Le cookie ne contient jamais le
288
+ jeton, seulement un identifiant de session opaque, oublié au redémarrage du serveur ou
289
+ sur « Se déconnecter ». Le navigateur ne stocke rien d'autre, et le jeton n'apparaît
290
+ jamais dans une URL.
290
291
 
291
- > Le jeton apparaissant dans l'URL, il se retrouve dans l'historique du navigateur. C'est
292
- > le prix du « jeton exigé sur absolument tout », y compris la page elle-même.
292
+ Cette session n'est reconnue que sur `/ui` et `/admin` : elle ne permet pas d'appeler
293
+ l'API des agents. Un site tiers ne peut donc pas faire agir un agent à travers le
294
+ navigateur de l'administrateur. Les routes `/admin` acceptent toujours le jeton admin en
295
+ `Authorization: Bearer` pour les scripts. Le formulaire est limité à 10 tentatives par
296
+ quart d'heure et par IP.
293
297
 
294
298
  La console n'écrit jamais : elle ne peut ni envoyer, ni supprimer, ni modifier.
295
299
 
@@ -330,10 +334,11 @@ Toutes les variables sont documentées dans [`.env.example`](.env.example). L'es
330
334
 
331
335
  ## API HTTP
332
336
 
333
- **Toutes** les routes exigent un jeton, présenté au choix par `Authorization: Bearer
334
- <token>`, l'en-tête `X-Messenger-Token`, ou `?token=` (réservé à la navigation navigateur).
335
- Les routes `/api/*` exigent en plus l'en-tête `X-Agent-Id`, et le jeton doit correspondre à
336
- cet agent lorsque `MESSENGER_AGENT_TOKENS` est utilisé.
337
+ **Toutes** les routes de données exigent un jeton, présenté par `Authorization: Bearer
338
+ <token>` ou par l'en-tête `X-Messenger-Token` — jamais dans l'URL. Les routes `/api/*`
339
+ exigent en plus l'en-tête `X-Agent-Id`, et le jeton doit correspondre à cet agent lorsque
340
+ `MESSENGER_AGENT_TOKENS` est utilisé. Les routes `/admin/*` acceptent aussi la session de
341
+ la console (cookie), qui elle n'ouvre rien d'autre.
337
342
 
338
343
  | Méthode | Route | Rôle |
339
344
  |---|---|---|
@@ -355,6 +360,7 @@ cet agent lorsque `MESSENGER_AGENT_TOKENS` est utilisé.
355
360
  | `GET` `POST` `DELETE` | `/api/folders[/:name]` | Gérer les dossiers |
356
361
  | `GET` | `/api/wait` | Attente longue (`timeout_seconds`, `folder`, `watcher`) |
357
362
  | `GET` | `/admin/overview` · `/admin/messages[/:id]` · `/admin/threads/:id` | Supervision, lecture seule |
363
+ | `POST` | `/ui/login` · `/ui/logout` | Session de la console (`{ "token": … }` → cookie `HttpOnly`) |
358
364
 
359
365
  ---
360
366
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Génère un jeton aléatoire cryptographiquement sûr.
3
3
  *
4
- * L'encodage `base64url` est choisi pour que le jeton passe tel quel dans l'URL de la
5
- * console web (`/ui/?token=…`) : ni `+`, ni `/`, ni `=` à échapper.
4
+ * L'encodage `base64url` évite tout caractère à échapper (`+`, `/`, `=`) : le jeton se
5
+ * copie tel quel dans un `.env`, un `.mcp.json` ou un en-tête HTTP.
6
6
  */
7
7
  export declare function generateToken(bytes?: number): string;
@@ -4,8 +4,8 @@ const DEFAULT_BYTES = 32;
4
4
  /**
5
5
  * Génère un jeton aléatoire cryptographiquement sûr.
6
6
  *
7
- * L'encodage `base64url` est choisi pour que le jeton passe tel quel dans l'URL de la
8
- * console web (`/ui/?token=…`) : ni `+`, ni `/`, ni `=` à échapper.
7
+ * L'encodage `base64url` évite tout caractère à échapper (`+`, `/`, `=`) : le jeton se
8
+ * copie tel quel dans un `.env`, un `.mcp.json` ou un en-tête HTTP.
9
9
  */
10
10
  export function generateToken(bytes = DEFAULT_BYTES) {
11
11
  return randomBytes(bytes).toString("base64url");
@@ -140,7 +140,7 @@ export function runSetupMcp({ cwd, force }) {
140
140
  console.log("");
141
141
  console.log("Le serveur boîte aux lettres doit tourner à part, avec le même jeton :");
142
142
  console.log(` MESSENGER_TOKEN=<votre-jeton> npx -y ${PACKAGE_NAME} --server`);
143
- console.log(` console web : ${DEFAULT_SERVER_URL}/ui/?token=<votre-jeton>`);
143
+ console.log(` console web : ${DEFAULT_SERVER_URL}/ui/ (le jeton y est demandé une fois)`);
144
144
  console.log("");
145
145
  console.log("Chaque session d'agent reçoit sa propre boîte : demandez son adresse à l'agent");
146
146
  console.log("(outil whoami) pour la transmettre à un autre agent, ou pour la reprendre plus tard.");
package/dist/src/index.js CHANGED
@@ -43,7 +43,7 @@ async function main() {
43
43
  "Ce jeton doit être identique aux trois endroits suivants :",
44
44
  " 1. le serveur : MESSENGER_TOKEN",
45
45
  " 2. chaque agent : MESSENGER_TOKEN dans son .mcp.json",
46
- " 3. la console web : http://127.0.0.1:3600/ui/?token=<jeton>",
46
+ " 3. la console web : http://127.0.0.1:3600/ui/ (à saisir dans le formulaire)",
47
47
  "",
48
48
  ].join("\n"));
49
49
  return;
@@ -1,5 +1,4 @@
1
1
  import express from "express";
2
- import fs from "node:fs";
3
2
  import helmet from "helmet";
4
3
  import path from "node:path";
5
4
  import { fileURLToPath } from "node:url";
@@ -9,6 +8,7 @@ import { z } from "zod";
9
8
  import { MessengerError, normalizeAgentId } from "../core/store.js";
10
9
  import { PRIORITIES } from "../core/types.js";
11
10
  import { WatcherRegistry } from "./watchers.js";
11
+ import { CONSOLE_COOKIE, ConsoleSessions, consoleSessionId } from "./console-session.js";
12
12
  import { APP_VERSION } from "../version.js";
13
13
  import { logger } from "../logger.js";
14
14
  const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "ui");
@@ -72,6 +72,7 @@ const waitQuerySchema = z.object({
72
72
  // attente longue, mais seul le premier prouve que l'agent est joignable en continu.
73
73
  watcher: z.enum(["true", "1"]).optional(),
74
74
  });
75
+ const loginSchema = z.object({ token: z.string().min(1).max(1024) });
75
76
  const adminListSchema = z.object({
76
77
  search: z.string().trim().min(1).optional(),
77
78
  agent: z.string().trim().min(1).optional(),
@@ -106,8 +107,65 @@ export function createApp({ store, config }) {
106
107
  standardHeaders: "draft-8",
107
108
  legacyHeaders: false,
108
109
  }));
109
- // Porte d'entrée unique : aucune requête, quelle qu'elle soit, n'est servie sans
110
- // jeton valide. Les contrôles par route (identité de l'agent, console) viennent ensuite.
110
+ // --- Console web ----------------------------------------------------------
111
+ // La page et ses fichiers ne contiennent aucun secret : ils sont servis à tous. Ce sont
112
+ // les données (`/admin`) qui sont protégées, par une session ouverte contre le jeton
113
+ // admin. Voir `console-session.ts` pour le raisonnement.
114
+ const sessions = new ConsoleSessions();
115
+ const cookieOptions = (req) => ({
116
+ httpOnly: true,
117
+ sameSite: "strict",
118
+ // `req.secure` n'est vrai derrière un proxy TLS que si `trust proxy` est configuré.
119
+ secure: req.secure,
120
+ path: "/",
121
+ });
122
+ // Limite propre au formulaire : le jeton fait 256 bits, mais autant fermer la porte.
123
+ const loginLimiter = rateLimit({ windowMs: 15 * 60_000, limit: 10, standardHeaders: "draft-8", legacyHeaders: false });
124
+ app.post("/ui/login", loginLimiter, (req, res) => {
125
+ const input = loginSchema.parse(req.body ?? {});
126
+ if (!safeEqual(input.token, config.adminToken)) {
127
+ res.status(401).json({ error: "invalid admin token" });
128
+ return;
129
+ }
130
+ res.cookie(CONSOLE_COOKIE, sessions.create(), { ...cookieOptions(req), maxAge: sessions.ttlMs });
131
+ res.status(204).end();
132
+ });
133
+ app.post("/ui/logout", (req, res) => {
134
+ sessions.revoke(consoleSessionId(req));
135
+ res.clearCookie(CONSOLE_COOKIE, cookieOptions(req));
136
+ res.status(204).end();
137
+ });
138
+ // `maxAge: 0` + ETag : la console reflète immédiatement une mise à jour du paquet,
139
+ // sans laisser un navigateur servir l'ancienne version depuis son cache.
140
+ app.use("/ui", express.static(UI_DIR, { index: "index.html", maxAge: 0, etag: true }));
141
+ app.get("/", (_req, res) => {
142
+ res.redirect("/ui/");
143
+ });
144
+ // --- Supervision (lecture seule) -----------------------------------------
145
+ // Montée avant la porte d'entrée : son contrôle est au moins aussi strict (session de
146
+ // console ou jeton admin), et la session par cookie n'est reconnue nulle part ailleurs.
147
+ const admin = express.Router();
148
+ admin.use(authenticateAdmin(config, sessions));
149
+ admin.get("/overview", (_req, res) => {
150
+ res.json(store.overview());
151
+ });
152
+ admin.get("/messages", (req, res) => {
153
+ const query = adminListSchema.parse(req.query);
154
+ res.json({ messages: store.listAllMessages(query) });
155
+ });
156
+ admin.get("/messages/:id", (req, res) => {
157
+ res.json(store.getMessageAdmin(String(req.params.id)));
158
+ });
159
+ admin.get("/threads/:id", (req, res) => {
160
+ const threadId = String(req.params.id);
161
+ const messages = store.listAllMessages({ thread_id: threadId, limit: 100 });
162
+ if (messages.length === 0)
163
+ throw new MessengerError(`thread not found: ${threadId}`, 404);
164
+ res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages: messages.slice().reverse() });
165
+ });
166
+ app.use("/admin", admin);
167
+ // Porte d'entrée de tout le reste : aucune requête n'est servie sans jeton valide. Le
168
+ // contrôle par route (identité de l'agent) vient ensuite.
111
169
  app.use(requireAnyToken(config));
112
170
  // --- État du service ------------------------------------------------------
113
171
  app.get("/health", (_req, res) => {
@@ -260,53 +318,6 @@ export function createApp({ store, config }) {
260
318
  req.once("close", onClose);
261
319
  });
262
320
  app.use("/api", api);
263
- // --- Supervision (lecture seule) -----------------------------------------
264
- const admin = express.Router();
265
- admin.use(authenticateAdmin(config));
266
- admin.get("/overview", (_req, res) => {
267
- res.json(store.overview());
268
- });
269
- admin.get("/messages", (req, res) => {
270
- const query = adminListSchema.parse(req.query);
271
- res.json({ messages: store.listAllMessages(query) });
272
- });
273
- admin.get("/messages/:id", (req, res) => {
274
- res.json(store.getMessageAdmin(String(req.params.id)));
275
- });
276
- admin.get("/threads/:id", (req, res) => {
277
- const threadId = String(req.params.id);
278
- const messages = store.listAllMessages({ thread_id: threadId, limit: 100 });
279
- if (messages.length === 0)
280
- throw new MessengerError(`thread not found: ${threadId}`, 404);
281
- res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages: messages.slice().reverse() });
282
- });
283
- app.use("/admin", admin);
284
- // --- Interface web --------------------------------------------------------
285
- // Un navigateur ne peut pas poser d'en-tête sur une navigation : la console s'ouvre
286
- // donc avec `?token=`. La page est servie avec ce jeton propagé sur ses ressources,
287
- // sans quoi le CSS et le script seraient refusés par la porte d'entrée.
288
- app.get(["/ui", "/ui/", "/ui/index.html"], (req, res, next) => {
289
- const token = presentedToken(req);
290
- if (!token) {
291
- next();
292
- return;
293
- }
294
- fs.readFile(path.join(UI_DIR, "index.html"), "utf-8", (error, html) => {
295
- if (error) {
296
- next(error);
297
- return;
298
- }
299
- const suffix = `?token=${encodeURIComponent(token)}`;
300
- res.type("html").send(html.replaceAll("./styles.css", `./styles.css${suffix}`).replaceAll("./app.js", `./app.js${suffix}`));
301
- });
302
- });
303
- // `maxAge: 0` + ETag : la console reflète immédiatement une mise à jour du paquet,
304
- // sans laisser un navigateur servir l'ancienne version depuis son cache.
305
- app.use("/ui", express.static(UI_DIR, { index: false, maxAge: 0, etag: true }));
306
- app.get("/", (req, res) => {
307
- const token = presentedToken(req);
308
- res.redirect(token ? `/ui/?token=${encodeURIComponent(token)}` : "/ui/");
309
- });
310
321
  // --- Erreurs --------------------------------------------------------------
311
322
  app.use((_req, res) => {
312
323
  res.status(404).json({ error: "not found" });
@@ -379,8 +390,13 @@ function authenticateAgent(config) {
379
390
  next();
380
391
  };
381
392
  }
382
- function authenticateAdmin(config) {
393
+ /** Session de console (navigateur) ou jeton admin (script, `curl`). */
394
+ function authenticateAdmin(config, sessions) {
383
395
  return (req, res, next) => {
396
+ if (sessions.has(consoleSessionId(req))) {
397
+ next();
398
+ return;
399
+ }
384
400
  const presented = presentedToken(req);
385
401
  if (!presented || !safeEqual(presented, config.adminToken)) {
386
402
  res.status(401).json({ error: "invalid or missing admin token" });
@@ -390,19 +406,15 @@ function authenticateAdmin(config) {
390
406
  };
391
407
  }
392
408
  /**
393
- * Jeton présenté par l'appelant, dans l'ordre de préférence : en-tête `Authorization`,
394
- * en-tête `X-Messenger-Token`, puis paramètre `?token=` — ce dernier existe pour la
395
- * navigation navigateur vers la console, qui ne peut pas poser d'en-tête.
409
+ * Jeton présenté par l'appelant : en-tête `Authorization`, sinon `X-Messenger-Token`.
410
+ * Jamais l'URL : un jeton en query string finit dans les journaux d'accès du proxy.
396
411
  */
397
412
  function presentedToken(req) {
398
413
  const header = req.header("authorization");
399
414
  if (header?.toLowerCase().startsWith("bearer "))
400
415
  return header.slice(7).trim() || null;
401
416
  const alt = req.header("x-messenger-token");
402
- if (alt?.trim())
403
- return alt.trim();
404
- const query = req.query.token;
405
- return typeof query === "string" && query.trim() ? query.trim() : null;
417
+ return alt?.trim() ? alt.trim() : null;
406
418
  }
407
419
  /** Comparaison à temps constant : évite de laisser fuiter le jeton octet par octet. */
408
420
  function safeEqual(a, b) {
@@ -0,0 +1,30 @@
1
+ import type { Request } from "express";
2
+ /**
3
+ * Sessions de la console web.
4
+ *
5
+ * Le navigateur ne peut pas poser d'en-tête sur une navigation, et faire transiter le
6
+ * jeton dans l'URL le dépose dans l'historique et les journaux d'accès du proxy. La
7
+ * console s'ouvre donc sans jeton, le demande une fois dans un formulaire, et reçoit en
8
+ * retour un identifiant de session opaque dans un cookie `HttpOnly`.
9
+ *
10
+ * Le cookie ne porte jamais le jeton lui-même : sa fuite ne donne qu'une session, à durée
11
+ * limitée, que le serveur oublie au redémarrage. Il n'est honoré que sur `/admin` et
12
+ * `/ui` : l'API des agents reste sur le Bearer, un site tiers ne peut donc pas faire
13
+ * agir un agent à travers le navigateur de l'administrateur.
14
+ */
15
+ export declare const CONSOLE_COOKIE = "messenger_console";
16
+ export declare class ConsoleSessions {
17
+ readonly ttlMs: number;
18
+ private readonly now;
19
+ private readonly sessions;
20
+ constructor(ttlMs?: number, now?: () => number);
21
+ /** Ouvre une session et rend son identifiant, seul contenu du cookie. */
22
+ create(): string;
23
+ /** Vrai si l'identifiant désigne une session encore valide. */
24
+ has(id: string | null): boolean;
25
+ revoke(id: string | null): void;
26
+ get size(): number;
27
+ private prune;
28
+ }
29
+ /** Identifiant de session porté par la requête, s'il y en a un. */
30
+ export declare function consoleSessionId(req: Request): string | null;
@@ -0,0 +1,85 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ /**
3
+ * Sessions de la console web.
4
+ *
5
+ * Le navigateur ne peut pas poser d'en-tête sur une navigation, et faire transiter le
6
+ * jeton dans l'URL le dépose dans l'historique et les journaux d'accès du proxy. La
7
+ * console s'ouvre donc sans jeton, le demande une fois dans un formulaire, et reçoit en
8
+ * retour un identifiant de session opaque dans un cookie `HttpOnly`.
9
+ *
10
+ * Le cookie ne porte jamais le jeton lui-même : sa fuite ne donne qu'une session, à durée
11
+ * limitée, que le serveur oublie au redémarrage. Il n'est honoré que sur `/admin` et
12
+ * `/ui` : l'API des agents reste sur le Bearer, un site tiers ne peut donc pas faire
13
+ * agir un agent à travers le navigateur de l'administrateur.
14
+ */
15
+ export const CONSOLE_COOKIE = "messenger_console";
16
+ /** Durée de vie d'une session : assez pour une journée de supervision, pas plus. */
17
+ const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
18
+ export class ConsoleSessions {
19
+ ttlMs;
20
+ now;
21
+ sessions = new Map();
22
+ constructor(ttlMs = SESSION_TTL_MS, now = Date.now) {
23
+ this.ttlMs = ttlMs;
24
+ this.now = now;
25
+ }
26
+ /** Ouvre une session et rend son identifiant, seul contenu du cookie. */
27
+ create() {
28
+ this.prune();
29
+ const id = randomBytes(32).toString("base64url");
30
+ this.sessions.set(id, { expiresAt: this.now() + this.ttlMs });
31
+ return id;
32
+ }
33
+ /** Vrai si l'identifiant désigne une session encore valide. */
34
+ has(id) {
35
+ if (!id)
36
+ return false;
37
+ // Parcours à temps constant : la Map compare ses clés en temps variable, ce qui
38
+ // laisserait deviner un identifiant octet par octet.
39
+ let match = null;
40
+ const presented = Buffer.from(id);
41
+ for (const [key, session] of this.sessions) {
42
+ const candidate = Buffer.from(key);
43
+ if (candidate.length === presented.length && timingSafeEqual(candidate, presented))
44
+ match = session;
45
+ }
46
+ if (!match)
47
+ return false;
48
+ if (match.expiresAt <= this.now()) {
49
+ this.sessions.delete(id);
50
+ return false;
51
+ }
52
+ return true;
53
+ }
54
+ revoke(id) {
55
+ if (id)
56
+ this.sessions.delete(id);
57
+ }
58
+ get size() {
59
+ this.prune();
60
+ return this.sessions.size;
61
+ }
62
+ prune() {
63
+ const now = this.now();
64
+ for (const [id, session] of this.sessions) {
65
+ if (session.expiresAt <= now)
66
+ this.sessions.delete(id);
67
+ }
68
+ }
69
+ }
70
+ /** Identifiant de session porté par la requête, s'il y en a un. */
71
+ export function consoleSessionId(req) {
72
+ const header = req.headers.cookie;
73
+ if (!header)
74
+ return null;
75
+ for (const part of header.split(";")) {
76
+ const separator = part.indexOf("=");
77
+ if (separator < 0)
78
+ continue;
79
+ if (part.slice(0, separator).trim() !== CONSOLE_COOKIE)
80
+ continue;
81
+ const value = part.slice(separator + 1).trim();
82
+ return value ? decodeURIComponent(value) : null;
83
+ }
84
+ return null;
85
+ }
@@ -43,8 +43,7 @@ export async function runServerMode() {
43
43
  logger.info(`mcp-messenger v${APP_VERSION} listening on ${running.url} (loopback only)`);
44
44
  logger.info(`database: ${config.dbPath}`);
45
45
  logger.info(`auth: ${authMode} — required on every request`);
46
- // Le jeton n'est pas écrit dans les logs : seule sa provenance est rappelée.
47
- logger.info(`web console: ${running.url}/ui/?token=<MESSENGER_ADMIN_TOKEN>`);
46
+ logger.info(`web console: ${running.url}/ui/ (asks for MESSENGER_ADMIN_TOKEN once)`);
48
47
  let shuttingDown = false;
49
48
  const shutdown = (signal) => {
50
49
  if (shuttingDown)
@@ -1,295 +1,318 @@
1
- // Console de supervision : lecture seule sur les routes /admin du serveur.
2
- // Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement.
3
-
4
- const TOKEN_KEY = "messenger.admin.token";
5
- const REFRESH_MS = 5000;
6
-
7
- // Le serveur exige un jeton sur toute requête, y compris celle qui a servi cette page :
8
- // il est propagé sur l'URL de ce script. On le reprend tel quel comme jeton de session.
9
- const TOKEN_FROM_URL = new URL(import.meta.url).searchParams.get("token");
10
-
11
- const state = {
12
- token: TOKEN_FROM_URL || localStorage.getItem(TOKEN_KEY) || "",
13
- agent: null,
14
- search: "",
15
- selectedMessageId: null,
16
- threadId: null,
17
- threadTitle: null,
18
- };
19
-
20
- const el = {
21
- statusDot: document.getElementById("status-dot"),
22
- serverStats: document.getElementById("server-stats"),
23
- agents: document.getElementById("agents"),
24
- messages: document.getElementById("messages"),
25
- detail: document.getElementById("detail"),
26
- search: document.getElementById("search"),
27
- filterLabel: document.getElementById("filter-label"),
28
- clearFilters: document.getElementById("clear-filters"),
29
- refresh: document.getElementById("refresh"),
30
- autoRefresh: document.getElementById("auto-refresh"),
31
- tokenBar: document.getElementById("token-bar"),
32
- tokenInput: document.getElementById("token"),
33
- tokenError: document.getElementById("token-error"),
34
- saveToken: document.getElementById("save-token"),
35
- };
36
-
37
- async function api(path, params) {
38
- const url = new URL(path, window.location.origin);
39
- for (const [key, value] of Object.entries(params || {})) {
40
- if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, value);
41
- }
42
- const headers = { accept: "application/json" };
43
- if (state.token) headers.authorization = "Bearer " + state.token;
44
-
45
- const response = await fetch(url, { headers });
46
- if (response.status === 401) {
47
- el.tokenBar.hidden = false;
48
- throw new Error("unauthorized");
49
- }
50
- if (!response.ok) {
51
- const payload = await response.json().catch(() => ({}));
52
- throw new Error(payload.error || response.statusText);
53
- }
54
- return response.json();
55
- }
56
-
57
- /** Remplace le contenu du panneau de détail (et retire l'état « vide » hérité du HTML). */
58
- function setDetail(child, empty) {
59
- el.detail.className = empty ? "empty" : "detail-content";
60
- el.detail.replaceChildren(child);
61
- }
62
-
63
- function node(tag, className, text) {
64
- const element = document.createElement(tag);
65
- if (className) element.className = className;
66
- if (text !== undefined) element.textContent = text;
67
- return element;
68
- }
69
-
70
- function formatDate(iso) {
71
- const date = new Date(iso);
72
- return Number.isNaN(date.getTime()) ? iso : date.toLocaleString();
73
- }
74
-
75
- /** Le titre suit l'id : le bandeau de filtre doit rester lisible, pas afficher un id nu. */
76
- function setThreadFilter(threadId, title) {
77
- state.threadId = threadId;
78
- state.threadTitle = title;
79
- }
80
-
81
- function renderAgents(overview) {
82
- el.serverStats.textContent =
83
- overview.messages + " message(s) · " + overview.threads + " fil(s) · " + overview.agents.length + " agent(s)";
84
- el.statusDot.className = "dot ok";
85
- el.agents.replaceChildren();
86
-
87
- for (const agent of overview.agents) {
88
- const item = node("li", state.agent === agent.id ? "selected" : "");
89
- // Le nom d'abord : l'adresse ne dit rien à l'utilisateur, elle reste là pour être copiée.
90
- item.appendChild(node("div", "name", agent.display_name || "sans nom"));
91
- item.appendChild(node("div", "id", agent.id));
92
- item.appendChild(
93
- node("div", "muted", "envoyés " + agent.sent + " · reçus " + agent.received + " · non lus " + agent.unread)
94
- );
95
- // La liste est classée sur cette date : sans elle, l'ordre paraîtrait arbitraire.
96
- item.appendChild(
97
- node("div", "muted", agent.last_activity_at ? "activité " + formatDate(agent.last_activity_at) : "aucune activité")
98
- );
99
- if (!agent.registered) item.appendChild(node("div", "muted", "jamais connecté"));
100
- item.addEventListener("click", () => {
101
- state.agent = state.agent === agent.id ? null : agent.id;
102
- setThreadFilter(null, null);
103
- refresh();
104
- });
105
- el.agents.appendChild(item);
106
- }
107
- }
108
-
109
- function renderMessages(messages) {
110
- el.filterLabel.textContent = [
111
- state.agent ? "agent : " + state.agent : null,
112
- state.threadId ? "fil : " + (state.threadTitle || state.threadId) : null,
113
- ]
114
- .filter(Boolean)
115
- .join(" · ");
116
-
117
- el.messages.replaceChildren();
118
- if (messages.length === 0) {
119
- el.messages.appendChild(node("li", "empty", "Aucun message."));
120
- return;
121
- }
122
-
123
- for (const message of messages) {
124
- const item = node("li", state.selectedMessageId === message.message_id ? "selected" : "");
125
- const subject = node("div", "subject", message.subject);
126
- if (message.priority !== "normal") {
127
- const badge = node("span", "badge" + (message.priority === "high" ? " high" : ""), message.priority);
128
- subject.appendChild(badge);
129
- }
130
- item.appendChild(subject);
131
- // Le nom du fil dit de quelle conversation relève le message ; sans nom, rien à afficher.
132
- if (message.thread_title) item.appendChild(node("div", "thread-name", message.thread_title));
133
- item.appendChild(
134
- node("div", "route", message.from + " → " + (message.to.join(", ") || "—") + " · " + formatDate(message.created_at))
135
- );
136
- item.appendChild(node("div", "preview", message.preview));
137
- item.addEventListener("click", () => openMessage(message.message_id));
138
- el.messages.appendChild(item);
139
- }
140
- }
141
-
142
- async function openMessage(messageId) {
143
- state.selectedMessageId = messageId;
144
- try {
145
- const message = await api("/admin/messages/" + encodeURIComponent(messageId));
146
- renderDetail(message);
147
- await refresh({ keepDetail: true });
148
- } catch (error) {
149
- setDetail(node("div", "error", error.message), false);
150
- }
151
- }
152
-
153
- function renderDetail(message) {
154
- const container = node("div", "detail");
155
- container.appendChild(node("h3", null, message.subject));
156
- container.appendChild(
157
- node(
158
- "div",
159
- "meta",
160
- "De " +
161
- message.from +
162
- " · À " +
163
- (message.to.join(", ") || "—") +
164
- (message.cc.length ? " · Cc " + message.cc.join(", ") : "") +
165
- "\n" +
166
- formatDate(message.created_at) +
167
- " · priorité " +
168
- message.priority +
169
- "\nid " +
170
- message.message_id +
171
- " · fil " +
172
- (message.thread_title ? message.thread_title + " (" + message.thread_id + ")" : message.thread_id)
173
- )
174
- );
175
-
176
- const threadButton = node("button", null, "Voir le fil complet");
177
- threadButton.addEventListener("click", () => showThread(message.thread_id));
178
- container.appendChild(threadButton);
179
-
180
- container.appendChild(node("div", "body", message.body));
181
-
182
- const deliveries = node("div", "deliveries");
183
- deliveries.appendChild(node("h2", null, "Distribution"));
184
- const list = node("ul");
185
- for (const delivery of message.deliveries) {
186
- list.appendChild(
187
- node(
188
- "li",
189
- null,
190
- delivery.agent_id +
191
- " · " +
192
- delivery.role +
193
- " · dossier " +
194
- delivery.folder +
195
- " · " +
196
- (delivery.read ? "lu" : "non lu")
197
- )
198
- );
199
- }
200
- deliveries.appendChild(list);
201
- container.appendChild(deliveries);
202
-
203
- setDetail(container, false);
204
- }
205
-
206
- async function showThread(threadId) {
207
- try {
208
- const data = await api("/admin/threads/" + encodeURIComponent(threadId));
209
- const container = node("div", "detail");
210
- const title = (data.thread && data.thread.title) || null;
211
- container.appendChild(node("h3", null, (title || "Fil " + threadId) + " — " + data.messages.length + " message(s)"));
212
- if (title) container.appendChild(node("div", "meta", "fil " + threadId));
213
- if (data.thread && data.thread.description) {
214
- container.appendChild(node("div", "thread-description", data.thread.description));
215
- }
216
-
217
- const filterButton = node("button", null, "Filtrer la liste sur ce fil");
218
- filterButton.addEventListener("click", () => {
219
- setThreadFilter(threadId, title);
220
- refresh({ keepDetail: true });
221
- });
222
- container.appendChild(filterButton);
223
-
224
- for (const message of data.messages) {
225
- const block = node("div", "thread-message");
226
- block.appendChild(node("div", "subject", message.subject));
227
- block.appendChild(
228
- node("div", "meta", message.from + " → " + (message.to.join(", ") || "—") + " · " + formatDate(message.created_at))
229
- );
230
- block.appendChild(node("div", "preview", message.preview));
231
- const openButton = node("button", "link", "ouvrir");
232
- openButton.addEventListener("click", () => openMessage(message.message_id));
233
- block.appendChild(openButton);
234
- container.appendChild(block);
235
- }
236
- setDetail(container, false);
237
- } catch (error) {
238
- setDetail(node("div", "error", error.message), false);
239
- }
240
- }
241
-
242
- async function refresh(options) {
243
- try {
244
- const [overview, list] = await Promise.all([
245
- api("/admin/overview"),
246
- api("/admin/messages", { agent: state.agent, search: state.search, thread_id: state.threadId, limit: 100 }),
247
- ]);
248
- el.tokenBar.hidden = true;
249
- renderAgents(overview);
250
- renderMessages(list.messages);
251
- if (!options || !options.keepDetail) {
252
- if (!state.selectedMessageId) {
253
- setDetail(node("div", null, "Sélectionnez un message pour voir son contenu."), true);
254
- }
255
- }
256
- } catch (error) {
257
- el.statusDot.className = "dot ko";
258
- if (error.message === "unauthorized") {
259
- el.serverStats.textContent = "authentification requise";
260
- } else {
261
- el.serverStats.textContent = "erreur : " + error.message;
262
- }
263
- }
264
- }
265
-
266
- el.refresh.addEventListener("click", () => refresh({ keepDetail: true }));
267
- el.clearFilters.addEventListener("click", () => {
268
- state.agent = null;
269
- setThreadFilter(null, null);
270
- state.search = "";
271
- el.search.value = "";
272
- refresh({ keepDetail: true });
273
- });
274
-
275
- let searchTimer = null;
276
- el.search.addEventListener("input", (event) => {
277
- state.search = event.target.value.trim();
278
- clearTimeout(searchTimer);
279
- searchTimer = setTimeout(() => refresh({ keepDetail: true }), 250);
280
- });
281
-
282
- el.saveToken.addEventListener("click", () => {
283
- state.token = el.tokenInput.value.trim();
284
- localStorage.setItem(TOKEN_KEY, state.token);
285
- el.tokenError.textContent = "";
286
- refresh({ keepDetail: true }).then(() => {
287
- if (!el.tokenBar.hidden) el.tokenError.textContent = "jeton refusé";
288
- });
289
- });
290
-
291
- setInterval(() => {
292
- if (el.autoRefresh.checked) refresh({ keepDetail: true });
293
- }, REFRESH_MS);
294
-
295
- refresh();
1
+ // Console de supervision : lecture seule sur les routes /admin du serveur.
2
+ // Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement.
3
+
4
+ const REFRESH_MS = 5000;
5
+
6
+ // L'authentification tient dans un cookie de session HttpOnly posé par `/ui/login` :
7
+ // ce script ne voit jamais le jeton, et ne stocke rien dans le navigateur.
8
+
9
+ const state = {
10
+ agent: null,
11
+ search: "",
12
+ selectedMessageId: null,
13
+ threadId: null,
14
+ threadTitle: null,
15
+ };
16
+
17
+ const el = {
18
+ statusDot: document.getElementById("status-dot"),
19
+ serverStats: document.getElementById("server-stats"),
20
+ agents: document.getElementById("agents"),
21
+ messages: document.getElementById("messages"),
22
+ detail: document.getElementById("detail"),
23
+ search: document.getElementById("search"),
24
+ filterLabel: document.getElementById("filter-label"),
25
+ clearFilters: document.getElementById("clear-filters"),
26
+ refresh: document.getElementById("refresh"),
27
+ autoRefresh: document.getElementById("auto-refresh"),
28
+ logout: document.getElementById("logout"),
29
+ loginForm: document.getElementById("login-form"),
30
+ tokenInput: document.getElementById("token"),
31
+ tokenError: document.getElementById("token-error"),
32
+ };
33
+
34
+ async function api(path, params) {
35
+ const url = new URL(path, window.location.origin);
36
+ for (const [key, value] of Object.entries(params || {})) {
37
+ if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, value);
38
+ }
39
+
40
+ const response = await fetch(url, { headers: { accept: "application/json" }, credentials: "same-origin" });
41
+ if (response.status === 401) {
42
+ setLoggedIn(false);
43
+ throw new Error("unauthorized");
44
+ }
45
+ if (!response.ok) {
46
+ const payload = await response.json().catch(() => ({}));
47
+ throw new Error(payload.error || response.statusText);
48
+ }
49
+ return response.json();
50
+ }
51
+
52
+ function setLoggedIn(loggedIn) {
53
+ el.loginForm.hidden = loggedIn;
54
+ el.logout.hidden = !loggedIn;
55
+ if (!loggedIn) el.tokenInput.focus();
56
+ }
57
+
58
+ /** Remplace le contenu du panneau de détail (et retire l'état « vide » hérité du HTML). */
59
+ function setDetail(child, empty) {
60
+ el.detail.className = empty ? "empty" : "detail-content";
61
+ el.detail.replaceChildren(child);
62
+ }
63
+
64
+ function node(tag, className, text) {
65
+ const element = document.createElement(tag);
66
+ if (className) element.className = className;
67
+ if (text !== undefined) element.textContent = text;
68
+ return element;
69
+ }
70
+
71
+ function formatDate(iso) {
72
+ const date = new Date(iso);
73
+ return Number.isNaN(date.getTime()) ? iso : date.toLocaleString();
74
+ }
75
+
76
+ /** Le titre suit l'id : le bandeau de filtre doit rester lisible, pas afficher un id nu. */
77
+ function setThreadFilter(threadId, title) {
78
+ state.threadId = threadId;
79
+ state.threadTitle = title;
80
+ }
81
+
82
+ function renderAgents(overview) {
83
+ el.serverStats.textContent =
84
+ overview.messages + " message(s) · " + overview.threads + " fil(s) · " + overview.agents.length + " agent(s)";
85
+ el.statusDot.className = "dot ok";
86
+ el.agents.replaceChildren();
87
+
88
+ for (const agent of overview.agents) {
89
+ const item = node("li", state.agent === agent.id ? "selected" : "");
90
+ // Le nom d'abord : l'adresse ne dit rien à l'utilisateur, elle reste là pour être copiée.
91
+ item.appendChild(node("div", "name", agent.display_name || "sans nom"));
92
+ item.appendChild(node("div", "id", agent.id));
93
+ item.appendChild(
94
+ node("div", "muted", "envoyés " + agent.sent + " · reçus " + agent.received + " · non lus " + agent.unread)
95
+ );
96
+ // La liste est classée sur cette date : sans elle, l'ordre paraîtrait arbitraire.
97
+ item.appendChild(
98
+ node("div", "muted", agent.last_activity_at ? "activité " + formatDate(agent.last_activity_at) : "aucune activité")
99
+ );
100
+ if (!agent.registered) item.appendChild(node("div", "muted", "jamais connecté"));
101
+ item.addEventListener("click", () => {
102
+ state.agent = state.agent === agent.id ? null : agent.id;
103
+ setThreadFilter(null, null);
104
+ refresh();
105
+ });
106
+ el.agents.appendChild(item);
107
+ }
108
+ }
109
+
110
+ function renderMessages(messages) {
111
+ el.filterLabel.textContent = [
112
+ state.agent ? "agent : " + state.agent : null,
113
+ state.threadId ? "fil : " + (state.threadTitle || state.threadId) : null,
114
+ ]
115
+ .filter(Boolean)
116
+ .join(" · ");
117
+
118
+ el.messages.replaceChildren();
119
+ if (messages.length === 0) {
120
+ el.messages.appendChild(node("li", "empty", "Aucun message."));
121
+ return;
122
+ }
123
+
124
+ for (const message of messages) {
125
+ const item = node("li", state.selectedMessageId === message.message_id ? "selected" : "");
126
+ const subject = node("div", "subject", message.subject);
127
+ if (message.priority !== "normal") {
128
+ const badge = node("span", "badge" + (message.priority === "high" ? " high" : ""), message.priority);
129
+ subject.appendChild(badge);
130
+ }
131
+ item.appendChild(subject);
132
+ // Le nom du fil dit de quelle conversation relève le message ; sans nom, rien à afficher.
133
+ if (message.thread_title) item.appendChild(node("div", "thread-name", message.thread_title));
134
+ item.appendChild(
135
+ node("div", "route", message.from + " → " + (message.to.join(", ") || "—") + " · " + formatDate(message.created_at))
136
+ );
137
+ item.appendChild(node("div", "preview", message.preview));
138
+ item.addEventListener("click", () => openMessage(message.message_id));
139
+ el.messages.appendChild(item);
140
+ }
141
+ }
142
+
143
+ async function openMessage(messageId) {
144
+ state.selectedMessageId = messageId;
145
+ try {
146
+ const message = await api("/admin/messages/" + encodeURIComponent(messageId));
147
+ renderDetail(message);
148
+ await refresh({ keepDetail: true });
149
+ } catch (error) {
150
+ setDetail(node("div", "error", error.message), false);
151
+ }
152
+ }
153
+
154
+ function renderDetail(message) {
155
+ const container = node("div", "detail");
156
+ container.appendChild(node("h3", null, message.subject));
157
+ container.appendChild(
158
+ node(
159
+ "div",
160
+ "meta",
161
+ "De " +
162
+ message.from +
163
+ " · À " +
164
+ (message.to.join(", ") || "—") +
165
+ (message.cc.length ? " · Cc " + message.cc.join(", ") : "") +
166
+ "\n" +
167
+ formatDate(message.created_at) +
168
+ " · priorité " +
169
+ message.priority +
170
+ "\nid " +
171
+ message.message_id +
172
+ " · fil " +
173
+ (message.thread_title ? message.thread_title + " (" + message.thread_id + ")" : message.thread_id)
174
+ )
175
+ );
176
+
177
+ const threadButton = node("button", null, "Voir le fil complet");
178
+ threadButton.addEventListener("click", () => showThread(message.thread_id));
179
+ container.appendChild(threadButton);
180
+
181
+ container.appendChild(node("div", "body", message.body));
182
+
183
+ const deliveries = node("div", "deliveries");
184
+ deliveries.appendChild(node("h2", null, "Distribution"));
185
+ const list = node("ul");
186
+ for (const delivery of message.deliveries) {
187
+ list.appendChild(
188
+ node(
189
+ "li",
190
+ null,
191
+ delivery.agent_id +
192
+ " · " +
193
+ delivery.role +
194
+ " · dossier " +
195
+ delivery.folder +
196
+ " · " +
197
+ (delivery.read ? "lu" : "non lu")
198
+ )
199
+ );
200
+ }
201
+ deliveries.appendChild(list);
202
+ container.appendChild(deliveries);
203
+
204
+ setDetail(container, false);
205
+ }
206
+
207
+ async function showThread(threadId) {
208
+ try {
209
+ const data = await api("/admin/threads/" + encodeURIComponent(threadId));
210
+ const container = node("div", "detail");
211
+ const title = (data.thread && data.thread.title) || null;
212
+ container.appendChild(node("h3", null, (title || "Fil " + threadId) + " — " + data.messages.length + " message(s)"));
213
+ if (title) container.appendChild(node("div", "meta", "fil " + threadId));
214
+ if (data.thread && data.thread.description) {
215
+ container.appendChild(node("div", "thread-description", data.thread.description));
216
+ }
217
+
218
+ const filterButton = node("button", null, "Filtrer la liste sur ce fil");
219
+ filterButton.addEventListener("click", () => {
220
+ setThreadFilter(threadId, title);
221
+ refresh({ keepDetail: true });
222
+ });
223
+ container.appendChild(filterButton);
224
+
225
+ for (const message of data.messages) {
226
+ const block = node("div", "thread-message");
227
+ block.appendChild(node("div", "subject", message.subject));
228
+ block.appendChild(
229
+ node("div", "meta", message.from + " → " + (message.to.join(", ") || "—") + " · " + formatDate(message.created_at))
230
+ );
231
+ block.appendChild(node("div", "preview", message.preview));
232
+ const openButton = node("button", "link", "ouvrir");
233
+ openButton.addEventListener("click", () => openMessage(message.message_id));
234
+ block.appendChild(openButton);
235
+ container.appendChild(block);
236
+ }
237
+ setDetail(container, false);
238
+ } catch (error) {
239
+ setDetail(node("div", "error", error.message), false);
240
+ }
241
+ }
242
+
243
+ async function refresh(options) {
244
+ try {
245
+ const [overview, list] = await Promise.all([
246
+ api("/admin/overview"),
247
+ api("/admin/messages", { agent: state.agent, search: state.search, thread_id: state.threadId, limit: 100 }),
248
+ ]);
249
+ setLoggedIn(true);
250
+ renderAgents(overview);
251
+ renderMessages(list.messages);
252
+ if (!options || !options.keepDetail) {
253
+ if (!state.selectedMessageId) {
254
+ setDetail(node("div", null, "Sélectionnez un message pour voir son contenu."), true);
255
+ }
256
+ }
257
+ } catch (error) {
258
+ el.statusDot.className = "dot ko";
259
+ if (error.message === "unauthorized") {
260
+ el.serverStats.textContent = "authentification requise";
261
+ } else {
262
+ el.serverStats.textContent = "erreur : " + error.message;
263
+ }
264
+ }
265
+ }
266
+
267
+ el.refresh.addEventListener("click", () => refresh({ keepDetail: true }));
268
+ el.clearFilters.addEventListener("click", () => {
269
+ state.agent = null;
270
+ setThreadFilter(null, null);
271
+ state.search = "";
272
+ el.search.value = "";
273
+ refresh({ keepDetail: true });
274
+ });
275
+
276
+ let searchTimer = null;
277
+ el.search.addEventListener("input", (event) => {
278
+ state.search = event.target.value.trim();
279
+ clearTimeout(searchTimer);
280
+ searchTimer = setTimeout(() => refresh({ keepDetail: true }), 250);
281
+ });
282
+
283
+ el.loginForm.addEventListener("submit", async (event) => {
284
+ event.preventDefault();
285
+ el.tokenError.textContent = "";
286
+ const token = el.tokenInput.value.trim();
287
+ if (!token) return;
288
+ const response = await fetch("/ui/login", {
289
+ method: "POST",
290
+ headers: { "content-type": "application/json" },
291
+ credentials: "same-origin",
292
+ body: JSON.stringify({ token }),
293
+ }).catch(() => null);
294
+ // Le champ est vidé dans tous les cas : le jeton ne reste pas affiché dans la page.
295
+ el.tokenInput.value = "";
296
+ if (!response || !response.ok) {
297
+ el.tokenError.textContent = response && response.status === 429 ? "trop de tentatives, réessayez plus tard" : "jeton refusé";
298
+ return;
299
+ }
300
+ refresh();
301
+ });
302
+
303
+ el.logout.addEventListener("click", async () => {
304
+ await fetch("/ui/logout", { method: "POST", credentials: "same-origin" }).catch(() => null);
305
+ setLoggedIn(false);
306
+ el.statusDot.className = "dot";
307
+ el.serverStats.textContent = "déconnecté";
308
+ el.agents.replaceChildren();
309
+ el.messages.replaceChildren();
310
+ state.selectedMessageId = null;
311
+ setDetail(node("div", null, "Sélectionnez un message pour voir son contenu."), true);
312
+ });
313
+
314
+ setInterval(() => {
315
+ if (el.autoRefresh.checked && el.loginForm.hidden) refresh({ keepDetail: true });
316
+ }, REFRESH_MS);
317
+
318
+ refresh();
@@ -19,15 +19,16 @@
19
19
  rafraîchissement auto
20
20
  </label>
21
21
  <button id="refresh">Rafraîchir</button>
22
+ <button id="logout" hidden>Se déconnecter</button>
22
23
  </div>
23
24
  </header>
24
25
 
25
- <div id="token-bar" class="token-bar" hidden>
26
+ <form id="login-form" class="token-bar" hidden>
26
27
  <label for="token">Jeton d'accès requis</label>
27
- <input type="password" id="token" placeholder="MESSENGER_ADMIN_TOKEN ou MESSENGER_TOKEN" />
28
- <button id="save-token">Se connecter</button>
28
+ <input type="password" id="token" autocomplete="current-password" placeholder="MESSENGER_ADMIN_TOKEN" />
29
+ <button type="submit">Se connecter</button>
29
30
  <span class="error" id="token-error"></span>
30
- </div>
31
+ </form>
31
32
 
32
33
  <main>
33
34
  <aside class="panel agents">
@@ -121,6 +121,7 @@ input[type="password"] {
121
121
  }
122
122
 
123
123
  .token-bar {
124
+ margin: 0;
124
125
  display: flex;
125
126
  align-items: center;
126
127
  gap: 10px;
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.5";
1
+ export declare const APP_VERSION = "1.0.6";
@@ -1,2 +1,2 @@
1
1
  // Généré par scripts/generate-version.js — ne pas modifier à la main.
2
- export const APP_VERSION = "1.0.5";
2
+ export const APP_VERSION = "1.0.6";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-messenger",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",