@imenam/mcp-messenger 1.0.4 → 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.
@@ -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,275 +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
- };
18
-
19
- const el = {
20
- statusDot: document.getElementById("status-dot"),
21
- serverStats: document.getElementById("server-stats"),
22
- agents: document.getElementById("agents"),
23
- messages: document.getElementById("messages"),
24
- detail: document.getElementById("detail"),
25
- search: document.getElementById("search"),
26
- filterLabel: document.getElementById("filter-label"),
27
- clearFilters: document.getElementById("clear-filters"),
28
- refresh: document.getElementById("refresh"),
29
- autoRefresh: document.getElementById("auto-refresh"),
30
- tokenBar: document.getElementById("token-bar"),
31
- tokenInput: document.getElementById("token"),
32
- tokenError: document.getElementById("token-error"),
33
- saveToken: document.getElementById("save-token"),
34
- };
35
-
36
- async function api(path, params) {
37
- const url = new URL(path, window.location.origin);
38
- for (const [key, value] of Object.entries(params || {})) {
39
- if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, value);
40
- }
41
- const headers = { accept: "application/json" };
42
- if (state.token) headers.authorization = "Bearer " + state.token;
43
-
44
- const response = await fetch(url, { headers });
45
- if (response.status === 401) {
46
- el.tokenBar.hidden = false;
47
- throw new Error("unauthorized");
48
- }
49
- if (!response.ok) {
50
- const payload = await response.json().catch(() => ({}));
51
- throw new Error(payload.error || response.statusText);
52
- }
53
- return response.json();
54
- }
55
-
56
- /** Remplace le contenu du panneau de détail (et retire l'état « vide » hérité du HTML). */
57
- function setDetail(child, empty) {
58
- el.detail.className = empty ? "empty" : "detail-content";
59
- el.detail.replaceChildren(child);
60
- }
61
-
62
- function node(tag, className, text) {
63
- const element = document.createElement(tag);
64
- if (className) element.className = className;
65
- if (text !== undefined) element.textContent = text;
66
- return element;
67
- }
68
-
69
- function formatDate(iso) {
70
- const date = new Date(iso);
71
- return Number.isNaN(date.getTime()) ? iso : date.toLocaleString();
72
- }
73
-
74
- function renderAgents(overview) {
75
- el.serverStats.textContent =
76
- overview.messages + " message(s) · " + overview.threads + " fil(s) · " + overview.agents.length + " agent(s)";
77
- el.statusDot.className = "dot ok";
78
- el.agents.replaceChildren();
79
-
80
- for (const agent of overview.agents) {
81
- const item = node("li", state.agent === agent.id ? "selected" : "");
82
- item.appendChild(node("div", "id", agent.display_name ? agent.id + " — " + agent.display_name : agent.id));
83
- item.appendChild(
84
- node("div", "muted", "envoyés " + agent.sent + " · reçus " + agent.received + " · non lus " + agent.unread)
85
- );
86
- if (!agent.registered) item.appendChild(node("div", "muted", "jamais connecté"));
87
- item.addEventListener("click", () => {
88
- state.agent = state.agent === agent.id ? null : agent.id;
89
- state.threadId = null;
90
- refresh();
91
- });
92
- el.agents.appendChild(item);
93
- }
94
- }
95
-
96
- function renderMessages(messages) {
97
- el.filterLabel.textContent = [
98
- state.agent ? "agent : " + state.agent : null,
99
- state.threadId ? "fil : " + state.threadId : null,
100
- ]
101
- .filter(Boolean)
102
- .join(" · ");
103
-
104
- el.messages.replaceChildren();
105
- if (messages.length === 0) {
106
- el.messages.appendChild(node("li", "empty", "Aucun message."));
107
- return;
108
- }
109
-
110
- for (const message of messages) {
111
- const item = node("li", state.selectedMessageId === message.message_id ? "selected" : "");
112
- const subject = node("div", "subject", message.subject);
113
- if (message.priority !== "normal") {
114
- const badge = node("span", "badge" + (message.priority === "high" ? " high" : ""), message.priority);
115
- subject.appendChild(badge);
116
- }
117
- item.appendChild(subject);
118
- item.appendChild(
119
- node("div", "route", message.from + " → " + (message.to.join(", ") || "—") + " · " + formatDate(message.created_at))
120
- );
121
- item.appendChild(node("div", "preview", message.preview));
122
- item.addEventListener("click", () => openMessage(message.message_id));
123
- el.messages.appendChild(item);
124
- }
125
- }
126
-
127
- async function openMessage(messageId) {
128
- state.selectedMessageId = messageId;
129
- try {
130
- const message = await api("/admin/messages/" + encodeURIComponent(messageId));
131
- renderDetail(message);
132
- await refresh({ keepDetail: true });
133
- } catch (error) {
134
- setDetail(node("div", "error", error.message), false);
135
- }
136
- }
137
-
138
- function renderDetail(message) {
139
- const container = node("div", "detail");
140
- container.appendChild(node("h3", null, message.subject));
141
- container.appendChild(
142
- node(
143
- "div",
144
- "meta",
145
- "De " +
146
- message.from +
147
- " · À " +
148
- (message.to.join(", ") || "—") +
149
- (message.cc.length ? " · Cc " + message.cc.join(", ") : "") +
150
- "\n" +
151
- formatDate(message.created_at) +
152
- " · priorité " +
153
- message.priority +
154
- "\nid " +
155
- message.message_id +
156
- " · fil " +
157
- message.thread_id
158
- )
159
- );
160
-
161
- const threadButton = node("button", null, "Voir le fil complet");
162
- threadButton.addEventListener("click", () => showThread(message.thread_id));
163
- container.appendChild(threadButton);
164
-
165
- container.appendChild(node("div", "body", message.body));
166
-
167
- const deliveries = node("div", "deliveries");
168
- deliveries.appendChild(node("h2", null, "Distribution"));
169
- const list = node("ul");
170
- for (const delivery of message.deliveries) {
171
- list.appendChild(
172
- node(
173
- "li",
174
- null,
175
- delivery.agent_id +
176
- " · " +
177
- delivery.role +
178
- " · dossier " +
179
- delivery.folder +
180
- " · " +
181
- (delivery.read ? "lu" : "non lu")
182
- )
183
- );
184
- }
185
- deliveries.appendChild(list);
186
- container.appendChild(deliveries);
187
-
188
- setDetail(container, false);
189
- }
190
-
191
- async function showThread(threadId) {
192
- try {
193
- const data = await api("/admin/threads/" + encodeURIComponent(threadId));
194
- const container = node("div", "detail");
195
- container.appendChild(node("h3", null, "Fil " + threadId + " — " + data.messages.length + " message(s)"));
196
-
197
- const filterButton = node("button", null, "Filtrer la liste sur ce fil");
198
- filterButton.addEventListener("click", () => {
199
- state.threadId = threadId;
200
- refresh({ keepDetail: true });
201
- });
202
- container.appendChild(filterButton);
203
-
204
- for (const message of data.messages) {
205
- const block = node("div", "thread-message");
206
- block.appendChild(node("div", "subject", message.subject));
207
- block.appendChild(
208
- node("div", "meta", message.from + " → " + (message.to.join(", ") || "—") + " · " + formatDate(message.created_at))
209
- );
210
- block.appendChild(node("div", "preview", message.preview));
211
- const openButton = node("button", "link", "ouvrir");
212
- openButton.addEventListener("click", () => openMessage(message.message_id));
213
- block.appendChild(openButton);
214
- container.appendChild(block);
215
- }
216
- setDetail(container, false);
217
- } catch (error) {
218
- setDetail(node("div", "error", error.message), false);
219
- }
220
- }
221
-
222
- async function refresh(options) {
223
- try {
224
- const [overview, list] = await Promise.all([
225
- api("/admin/overview"),
226
- api("/admin/messages", { agent: state.agent, search: state.search, thread_id: state.threadId, limit: 100 }),
227
- ]);
228
- el.tokenBar.hidden = true;
229
- renderAgents(overview);
230
- renderMessages(list.messages);
231
- if (!options || !options.keepDetail) {
232
- if (!state.selectedMessageId) {
233
- setDetail(node("div", null, "Sélectionnez un message pour voir son contenu."), true);
234
- }
235
- }
236
- } catch (error) {
237
- el.statusDot.className = "dot ko";
238
- if (error.message === "unauthorized") {
239
- el.serverStats.textContent = "authentification requise";
240
- } else {
241
- el.serverStats.textContent = "erreur : " + error.message;
242
- }
243
- }
244
- }
245
-
246
- el.refresh.addEventListener("click", () => refresh({ keepDetail: true }));
247
- el.clearFilters.addEventListener("click", () => {
248
- state.agent = null;
249
- state.threadId = null;
250
- state.search = "";
251
- el.search.value = "";
252
- refresh({ keepDetail: true });
253
- });
254
-
255
- let searchTimer = null;
256
- el.search.addEventListener("input", (event) => {
257
- state.search = event.target.value.trim();
258
- clearTimeout(searchTimer);
259
- searchTimer = setTimeout(() => refresh({ keepDetail: true }), 250);
260
- });
261
-
262
- el.saveToken.addEventListener("click", () => {
263
- state.token = el.tokenInput.value.trim();
264
- localStorage.setItem(TOKEN_KEY, state.token);
265
- el.tokenError.textContent = "";
266
- refresh({ keepDetail: true }).then(() => {
267
- if (!el.tokenBar.hidden) el.tokenError.textContent = "jeton refusé";
268
- });
269
- });
270
-
271
- setInterval(() => {
272
- if (el.autoRefresh.checked) refresh({ keepDetail: true });
273
- }, REFRESH_MS);
274
-
275
- 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">