@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 +23 -17
- package/dist/src/cli/generate-token.d.ts +2 -2
- package/dist/src/cli/generate-token.js +2 -2
- package/dist/src/cli/setup-mcp.js +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/server/app.js +70 -58
- package/dist/src/server/console-session.d.ts +30 -0
- package/dist/src/server/console-session.js +85 -0
- package/dist/src/server/start.js +1 -2
- package/dist/src/server/ui/app.js +318 -295
- package/dist/src/server/ui/index.html +5 -4
- package/dist/src/server/ui/styles.css +1 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
55
|
-
console
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
`
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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é
|
|
334
|
-
<token
|
|
335
|
-
|
|
336
|
-
|
|
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`
|
|
5
|
-
*
|
|
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`
|
|
8
|
-
*
|
|
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
|
|
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
|
|
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;
|
package/dist/src/server/app.js
CHANGED
|
@@ -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
|
-
//
|
|
110
|
-
//
|
|
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
|
-
|
|
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
|
|
394
|
-
* en
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/src/server/start.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
el.detail.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
state.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
el.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
item.appendChild(node("div", "
|
|
92
|
-
item.appendChild(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
item.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
state.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
item.appendChild(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
item.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
container
|
|
156
|
-
container.appendChild(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
"
|
|
160
|
-
"
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
(message.
|
|
165
|
-
"
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
threadButton
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
deliveries
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
filterButton
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
block
|
|
227
|
-
block.appendChild(
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
openButton
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
api("/admin/
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
el.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
searchTimer
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
el.tokenError.textContent = "";
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
<
|
|
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
|
|
28
|
-
<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
|
-
</
|
|
31
|
+
</form>
|
|
31
32
|
|
|
32
33
|
<main>
|
|
33
34
|
<aside class="panel agents">
|
package/dist/src/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const APP_VERSION = "1.0.
|
|
1
|
+
export declare const APP_VERSION = "1.0.6";
|
package/dist/src/version.js
CHANGED
|
@@ -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.
|
|
2
|
+
export const APP_VERSION = "1.0.6";
|