@imenam/mcp-messenger 1.0.6 → 1.0.7

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.
@@ -1,318 +1,320 @@
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();
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
+ // Deux noms, deux questions : l'agent dit d'où l'on écrit (le projet), la boîte de quoi
91
+ // cette session s'occupe. L'un sans l'autre ne suffit pas à s'y retrouver.
92
+ // L'adresse ne dit rien à l'utilisateur, elle reste là pour être copiée.
93
+ item.appendChild(node("div", "name", agent.agent_name || "agent sans nom"));
94
+ item.appendChild(node("div", "mailbox-name", agent.display_name || "boîte sans nom"));
95
+ item.appendChild(node("div", "id", agent.id));
96
+ item.appendChild(
97
+ node("div", "muted", "envoyés " + agent.sent + " · reçus " + agent.received + " · non lus " + agent.unread)
98
+ );
99
+ // La liste est classée sur cette date : sans elle, l'ordre paraîtrait arbitraire.
100
+ item.appendChild(
101
+ node("div", "muted", agent.last_activity_at ? "activité " + formatDate(agent.last_activity_at) : "aucune activité")
102
+ );
103
+ if (!agent.registered) item.appendChild(node("div", "muted", "jamais connecté"));
104
+ item.addEventListener("click", () => {
105
+ state.agent = state.agent === agent.id ? null : agent.id;
106
+ setThreadFilter(null, null);
107
+ refresh();
108
+ });
109
+ el.agents.appendChild(item);
110
+ }
111
+ }
112
+
113
+ function renderMessages(messages) {
114
+ el.filterLabel.textContent = [
115
+ state.agent ? "agent : " + state.agent : null,
116
+ state.threadId ? "fil : " + (state.threadTitle || state.threadId) : null,
117
+ ]
118
+ .filter(Boolean)
119
+ .join(" · ");
120
+
121
+ el.messages.replaceChildren();
122
+ if (messages.length === 0) {
123
+ el.messages.appendChild(node("li", "empty", "Aucun message."));
124
+ return;
125
+ }
126
+
127
+ for (const message of messages) {
128
+ const item = node("li", state.selectedMessageId === message.message_id ? "selected" : "");
129
+ const subject = node("div", "subject", message.subject);
130
+ if (message.priority !== "normal") {
131
+ const badge = node("span", "badge" + (message.priority === "high" ? " high" : ""), message.priority);
132
+ subject.appendChild(badge);
133
+ }
134
+ item.appendChild(subject);
135
+ // Le nom du fil dit de quelle conversation relève le message ; sans nom, rien à afficher.
136
+ if (message.thread_title) item.appendChild(node("div", "thread-name", message.thread_title));
137
+ item.appendChild(
138
+ node("div", "route", message.from + " → " + (message.to || "—") + " · " + formatDate(message.created_at))
139
+ );
140
+ item.appendChild(node("div", "preview", message.preview));
141
+ item.addEventListener("click", () => openMessage(message.message_id));
142
+ el.messages.appendChild(item);
143
+ }
144
+ }
145
+
146
+ async function openMessage(messageId) {
147
+ state.selectedMessageId = messageId;
148
+ try {
149
+ const message = await api("/admin/messages/" + encodeURIComponent(messageId));
150
+ renderDetail(message);
151
+ await refresh({ keepDetail: true });
152
+ } catch (error) {
153
+ setDetail(node("div", "error", error.message), false);
154
+ }
155
+ }
156
+
157
+ function renderDetail(message) {
158
+ const container = node("div", "detail");
159
+ container.appendChild(node("h3", null, message.subject));
160
+ container.appendChild(
161
+ node(
162
+ "div",
163
+ "meta",
164
+ "De " +
165
+ message.from +
166
+ " · À " +
167
+ (message.to || "—") +
168
+ "\n" +
169
+ formatDate(message.created_at) +
170
+ " · priorité " +
171
+ message.priority +
172
+ "\nid " +
173
+ message.message_id +
174
+ " · fil " +
175
+ (message.thread_title ? message.thread_title + " (" + message.thread_id + ")" : message.thread_id)
176
+ )
177
+ );
178
+
179
+ const threadButton = node("button", null, "Voir le fil complet");
180
+ threadButton.addEventListener("click", () => showThread(message.thread_id));
181
+ container.appendChild(threadButton);
182
+
183
+ container.appendChild(node("div", "body", message.body));
184
+
185
+ const deliveries = node("div", "deliveries");
186
+ deliveries.appendChild(node("h2", null, "Distribution"));
187
+ const list = node("ul");
188
+ for (const delivery of message.deliveries) {
189
+ list.appendChild(
190
+ node(
191
+ "li",
192
+ null,
193
+ delivery.agent_id +
194
+ " · " +
195
+ delivery.role +
196
+ " · dossier " +
197
+ delivery.folder +
198
+ " · " +
199
+ (delivery.read ? "lu" : "non lu")
200
+ )
201
+ );
202
+ }
203
+ deliveries.appendChild(list);
204
+ container.appendChild(deliveries);
205
+
206
+ setDetail(container, false);
207
+ }
208
+
209
+ async function showThread(threadId) {
210
+ try {
211
+ const data = await api("/admin/threads/" + encodeURIComponent(threadId));
212
+ const container = node("div", "detail");
213
+ const title = (data.thread && data.thread.title) || null;
214
+ container.appendChild(node("h3", null, (title || "Fil " + threadId) + " — " + data.messages.length + " message(s)"));
215
+ if (title) container.appendChild(node("div", "meta", "fil " + threadId));
216
+ if (data.thread && data.thread.description) {
217
+ container.appendChild(node("div", "thread-description", data.thread.description));
218
+ }
219
+
220
+ const filterButton = node("button", null, "Filtrer la liste sur ce fil");
221
+ filterButton.addEventListener("click", () => {
222
+ setThreadFilter(threadId, title);
223
+ refresh({ keepDetail: true });
224
+ });
225
+ container.appendChild(filterButton);
226
+
227
+ for (const message of data.messages) {
228
+ const block = node("div", "thread-message");
229
+ block.appendChild(node("div", "subject", message.subject));
230
+ block.appendChild(
231
+ node("div", "meta", message.from + " → " + (message.to || "—") + " · " + formatDate(message.created_at))
232
+ );
233
+ block.appendChild(node("div", "preview", message.preview));
234
+ const openButton = node("button", "link", "ouvrir");
235
+ openButton.addEventListener("click", () => openMessage(message.message_id));
236
+ block.appendChild(openButton);
237
+ container.appendChild(block);
238
+ }
239
+ setDetail(container, false);
240
+ } catch (error) {
241
+ setDetail(node("div", "error", error.message), false);
242
+ }
243
+ }
244
+
245
+ async function refresh(options) {
246
+ try {
247
+ const [overview, list] = await Promise.all([
248
+ api("/admin/overview"),
249
+ api("/admin/messages", { agent: state.agent, search: state.search, thread_id: state.threadId, limit: 100 }),
250
+ ]);
251
+ setLoggedIn(true);
252
+ renderAgents(overview);
253
+ renderMessages(list.messages);
254
+ if (!options || !options.keepDetail) {
255
+ if (!state.selectedMessageId) {
256
+ setDetail(node("div", null, "Sélectionnez un message pour voir son contenu."), true);
257
+ }
258
+ }
259
+ } catch (error) {
260
+ el.statusDot.className = "dot ko";
261
+ if (error.message === "unauthorized") {
262
+ el.serverStats.textContent = "authentification requise";
263
+ } else {
264
+ el.serverStats.textContent = "erreur : " + error.message;
265
+ }
266
+ }
267
+ }
268
+
269
+ el.refresh.addEventListener("click", () => refresh({ keepDetail: true }));
270
+ el.clearFilters.addEventListener("click", () => {
271
+ state.agent = null;
272
+ setThreadFilter(null, null);
273
+ state.search = "";
274
+ el.search.value = "";
275
+ refresh({ keepDetail: true });
276
+ });
277
+
278
+ let searchTimer = null;
279
+ el.search.addEventListener("input", (event) => {
280
+ state.search = event.target.value.trim();
281
+ clearTimeout(searchTimer);
282
+ searchTimer = setTimeout(() => refresh({ keepDetail: true }), 250);
283
+ });
284
+
285
+ el.loginForm.addEventListener("submit", async (event) => {
286
+ event.preventDefault();
287
+ el.tokenError.textContent = "";
288
+ const token = el.tokenInput.value.trim();
289
+ if (!token) return;
290
+ const response = await fetch("/ui/login", {
291
+ method: "POST",
292
+ headers: { "content-type": "application/json" },
293
+ credentials: "same-origin",
294
+ body: JSON.stringify({ token }),
295
+ }).catch(() => null);
296
+ // Le champ est vidé dans tous les cas : le jeton ne reste pas affiché dans la page.
297
+ el.tokenInput.value = "";
298
+ if (!response || !response.ok) {
299
+ el.tokenError.textContent = response && response.status === 429 ? "trop de tentatives, réessayez plus tard" : "jeton refusé";
300
+ return;
301
+ }
302
+ refresh();
303
+ });
304
+
305
+ el.logout.addEventListener("click", async () => {
306
+ await fetch("/ui/logout", { method: "POST", credentials: "same-origin" }).catch(() => null);
307
+ setLoggedIn(false);
308
+ el.statusDot.className = "dot";
309
+ el.serverStats.textContent = "déconnecté";
310
+ el.agents.replaceChildren();
311
+ el.messages.replaceChildren();
312
+ state.selectedMessageId = null;
313
+ setDetail(node("div", null, "Sélectionnez un message pour voir son contenu."), true);
314
+ });
315
+
316
+ setInterval(() => {
317
+ if (el.autoRefresh.checked && el.loginForm.hidden) refresh({ keepDetail: true });
318
+ }, REFRESH_MS);
319
+
320
+ refresh();