keepup-http 0.5.0__py3-none-any.whl

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.
Files changed (43) hide show
  1. keepup_http/__init__.py +38 -0
  2. keepup_http/api_docs.py +76 -0
  3. keepup_http/body_limit.py +156 -0
  4. keepup_http/events_api.py +237 -0
  5. keepup_http/factory.py +562 -0
  6. keepup_http/lock_routes.py +155 -0
  7. keepup_http/plugin.py +53 -0
  8. keepup_http/routes.py +549 -0
  9. keepup_http/static/css/aos.css +1 -0
  10. keepup_http/static/css/main_nebula.css +232 -0
  11. keepup_http/static/css/main_new.css +869 -0
  12. keepup_http/static/css/tailwind.css +1 -0
  13. keepup_http/static/index_nebula.html +282 -0
  14. keepup_http/static/index_new.html +275 -0
  15. keepup_http/static/js/aos.js +1 -0
  16. keepup_http/static/js/feather-icons.js +13 -0
  17. keepup_http/static/js/layout_bootstrap.js +29 -0
  18. keepup_http/static/js/main_new.js +2076 -0
  19. keepup_http/static/js/tailwind.js +83 -0
  20. keepup_http/static/modules/css/background_tasks.css +233 -0
  21. keepup_http/static/modules/css/cluster.css +16 -0
  22. keepup_http/static/modules/css/event_manager.css +386 -0
  23. keepup_http/static/modules/css/integration_logs.css +33 -0
  24. keepup_http/static/modules/css/metrics.css +115 -0
  25. keepup_http/static/modules/css/modules.css +189 -0
  26. keepup_http/static/modules/css/themes.css +563 -0
  27. keepup_http/static/modules/css/users.css +278 -0
  28. keepup_http/static/modules/js/background_tasks.js +695 -0
  29. keepup_http/static/modules/js/chart.js +14 -0
  30. keepup_http/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
  31. keepup_http/static/modules/js/cluster.js +374 -0
  32. keepup_http/static/modules/js/event_manager.js +1007 -0
  33. keepup_http/static/modules/js/integration_logs.js +795 -0
  34. keepup_http/static/modules/js/metrics.js +929 -0
  35. keepup_http/static/modules/js/modules.js +1128 -0
  36. keepup_http/static/modules/js/themes.js +684 -0
  37. keepup_http/static/modules/js/users.js +898 -0
  38. keepup_http/web.py +214 -0
  39. keepup_http-0.5.0.dist-info/METADATA +29 -0
  40. keepup_http-0.5.0.dist-info/RECORD +43 -0
  41. keepup_http-0.5.0.dist-info/WHEEL +5 -0
  42. keepup_http-0.5.0.dist-info/entry_points.txt +2 -0
  43. keepup_http-0.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2076 @@
1
+
2
+ let currentUser = null;
3
+
4
+ // endregion
5
+
6
+ /* ==========================================================================
7
+ Panel session.
8
+
9
+ The session is an httpOnly cookie the page cannot read. What the page keeps
10
+ under 'authToken' is the marker below, so sections that check for a token and
11
+ send `Bearer ${token}` keep working: the server reads `Bearer cookie` as no
12
+ header and uses the cookie. A change made by cookie must carry the CSRF value,
13
+ which the wrapper below adds to every same-origin request that is not a read.
14
+ ========================================================================== */
15
+ const SESSION_MARKER = 'cookie';
16
+ // The CSRF cookie carries the __Host- prefix over HTTPS (keepup-92): that name
17
+ // belongs to this exact host, so a sibling subdomain cannot plant it. The plain
18
+ // name is what a panel signed in before this release holds and is read for one
19
+ // release; the prefixed value wins when both are there.
20
+ const CSRF_COOKIES = ['__Host-ss_csrf', 'ss_csrf'];
21
+ const CSRF_HEADER = 'X-CSRF-Token';
22
+ const CSRF_SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
23
+
24
+ function readCookie(name) {
25
+ const prefix = name + '=';
26
+ const found = (document.cookie || '').split(';')
27
+ .map(part => part.trim())
28
+ .find(part => part.startsWith(prefix));
29
+ return found ? decodeURIComponent(found.slice(prefix.length)) : null;
30
+ }
31
+
32
+ function readCsrfCookie() {
33
+ for (const name of CSRF_COOKIES) {
34
+ const value = readCookie(name);
35
+ if (value) return value;
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function isSameOriginUrl(url) {
41
+ try {
42
+ return new URL(url, window.location.href).origin === window.location.origin;
43
+ } catch (error) {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ function installCsrfFetch(target) {
49
+ const originalFetch = target.fetch;
50
+ if (!originalFetch || originalFetch.csrfAware) {
51
+ return;
52
+ }
53
+ const wrapped = function (input, init) {
54
+ const request = typeof Request !== 'undefined' && input instanceof Request ? input : null;
55
+ const method = String((init && init.method) || (request && request.method) || 'GET').toUpperCase();
56
+ const url = request ? request.url : String(input);
57
+ const csrf = readCsrfCookie();
58
+ if (csrf && !CSRF_SAFE_METHODS.includes(method) && isSameOriginUrl(url)) {
59
+ const headers = new Headers((init && init.headers) || (request && request.headers) || undefined);
60
+ if (!headers.has(CSRF_HEADER)) {
61
+ headers.set(CSRF_HEADER, csrf);
62
+ }
63
+ init = Object.assign({}, init || {}, { headers });
64
+ }
65
+ return originalFetch.call(target, input, init);
66
+ };
67
+ wrapped.csrfAware = true;
68
+ target.fetch = wrapped;
69
+ }
70
+
71
+ installCsrfFetch(window);
72
+
73
+ const ROLE_ADMIN = 'ADMIN';
74
+ const ROLE_CLIENT = 'CLIENT';
75
+
76
+ // --- The roles a user holds ------------------------------------------------------
77
+ //
78
+ // A user holds a set of roles, and the panel decides by the set: somebody who is
79
+ // both an administrator and a client holds two, and comparing one field with
80
+ // ROLE_ADMIN would have made them choose. `role` is the deprecated mirror of the
81
+ // set and is still the answer for a user an application assembled itself; it
82
+ // goes away in keepup 0.3.0 (keepup-51). Cut out by these markers and executed
83
+ // under node -- see tests/user_roles_tests.py.
84
+ function userRolesOf(user) {
85
+ if (user && Array.isArray(user.roles) && user.roles.length) return user.roles;
86
+ return user && user.role ? [user.role] : [];
87
+ }
88
+
89
+ function userHasRole(user, role) {
90
+ return userRolesOf(user).indexOf(role) !== -1;
91
+ }
92
+
93
+ function currentUserIsAdmin() {
94
+ return userHasRole(currentUser, ROLE_ADMIN);
95
+ }
96
+
97
+ window.AppRoles = { of: userRolesOf, has: userHasRole };
98
+ // --- end of the roles a user holds ----------------------------------------------
99
+
100
+ /* ==========================================================================
101
+ What a section asks the shell to do (keepup-93).
102
+
103
+ A section used to put JavaScript in its markup -- an onclick attribute that
104
+ called editUser(7) -- and a browser served the panel with a
105
+ Content-Security-Policy refuses to run that at all (keepup/security.py): an
106
+ attribute is a script the page did not load, and it is exactly what a
107
+ stored-XSS hole (keepup-62) needs in order to become a running one. A section
108
+ now names an action it registered here and carries the values its handler
109
+ needs in data-* attributes:
110
+
111
+ <button data-action="users.edit" data-user-id="7">Edit</button>
112
+
113
+ KeepupActions.register({
114
+ 'users.edit': (element) => editUser(Number(element.dataset.userId)),
115
+ });
116
+
117
+ One listener per event kind on the document serves every section, including
118
+ the ones loaded later: nothing is bound per render, so a rebuilt table needs
119
+ no rebinding. Only a registered name is ever called -- markup can name an
120
+ action, never a function -- and a name nothing registered does nothing.
121
+ ========================================================================== */
122
+ // --- what a section asks the shell to do (keepup-93)
123
+ const KEEPUP_ACTION_ATTRIBUTE = 'data-action';
124
+
125
+ //: The elements whose action belongs to a change or a submit, not to a click.
126
+ //: A click on the button of a form whose action sits on the form itself would
127
+ //: otherwise run the same handler twice -- once for the click and once for the
128
+ //: submit it causes. A form answers its submit, a field its change.
129
+ const KEEPUP_CONTROL_TAGS = ['FORM', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION'];
130
+
131
+ const KeepupActions = (function () {
132
+ const handlers = new Map();
133
+
134
+ function register(name, handler) {
135
+ if (name && typeof name === 'object') {
136
+ Object.keys(name).forEach(function (key) { handlers.set(key, name[key]); });
137
+ return;
138
+ }
139
+ handlers.set(name, handler);
140
+ }
141
+
142
+ function actionOf(target) {
143
+ if (!target || typeof target.closest !== 'function') return null;
144
+ const element = target.closest('[' + KEEPUP_ACTION_ATTRIBUTE + ']');
145
+ return element && handlers.has(element.getAttribute(KEEPUP_ACTION_ATTRIBUTE))
146
+ ? element : null;
147
+ }
148
+
149
+ function handle(event) {
150
+ const element = actionOf(event.target);
151
+ if (!element) return;
152
+ if (event.type === 'click' && KEEPUP_CONTROL_TAGS.indexOf(element.tagName) !== -1) return;
153
+ const handler = handlers.get(element.getAttribute(KEEPUP_ACTION_ATTRIBUTE));
154
+ // A form is answered here rather than sent, and a link that only names
155
+ // an action is a button wearing an anchor.
156
+ if (event.type === 'submit' || element.tagName === 'A') event.preventDefault();
157
+ handler(element, event);
158
+ }
159
+
160
+ document.addEventListener('click', handle);
161
+ document.addEventListener('change', handle);
162
+ document.addEventListener('submit', handle);
163
+
164
+ return { register: register };
165
+ })();
166
+ window.KeepupActions = KeepupActions;
167
+ // --- end of what a section asks the shell to do --------------------------------
168
+
169
+
170
+ /* ==========================================================================
171
+ Adaptive layout.
172
+
173
+ The narrow/wide boundary is a design token, --layout-breakpoint, so a theme
174
+ can move it. A media query cannot read a custom property and there is no CSS
175
+ build step here to expand one, so the value is read once and the resulting
176
+ state is published as the .is-narrow class on <html>; every adaptive rule in
177
+ main_new.css matches on that class. The theme's head script sets the class
178
+ before the first paint -- this module only takes over and keeps it in step.
179
+ ========================================================================== */
180
+ const LAYOUT_NARROW_CLASS = 'is-narrow';
181
+ const LAYOUT_BREAKPOINT_FALLBACK = 768;
182
+ const LAYOUT_CHANGED_EVENT = 'app:layout:changed';
183
+
184
+ let layoutMediaQuery = null;
185
+
186
+ function readLayoutBreakpoint() {
187
+ const declared = getComputedStyle(document.documentElement)
188
+ .getPropertyValue('--layout-breakpoint');
189
+ const value = parseFloat(declared);
190
+ return Number.isFinite(value) && value > 0 ? value : LAYOUT_BREAKPOINT_FALLBACK;
191
+ }
192
+
193
+ function isNarrowLayout() {
194
+ return document.documentElement.classList.contains(LAYOUT_NARROW_CLASS);
195
+ }
196
+
197
+ function applyLayoutState(narrow) {
198
+ const changed = narrow !== isNarrowLayout();
199
+ document.documentElement.classList.toggle(LAYOUT_NARROW_CLASS, narrow);
200
+ if (!changed) return;
201
+ AppEventTarget.dispatchEvent(new CustomEvent(LAYOUT_CHANGED_EVENT, {
202
+ detail: { narrow }
203
+ }));
204
+ }
205
+
206
+ function initLayoutBreakpoint() {
207
+ // 0.02px below the token: matchMedia ranges are inclusive, so a plain
208
+ // max-width equal to the breakpoint would make both layouts true at exactly
209
+ // that width, and the md: utilities Tailwind builds from the same value
210
+ // start at the breakpoint itself.
211
+ layoutMediaQuery = window.matchMedia(`(max-width: ${readLayoutBreakpoint() - 0.02}px)`);
212
+ applyLayoutState(layoutMediaQuery.matches);
213
+ layoutMediaQuery.addEventListener('change', event => applyLayoutState(event.matches));
214
+ }
215
+
216
+ /* --------------------------------------------------------------------------
217
+ Navigation drawer. Below the breakpoint the sidebar floats over the content
218
+ instead of sitting beside it, so it needs a way out that a permanent column
219
+ never did: the backdrop, Esc, and picking a section all close it. Without
220
+ the last one the user lands on the section they chose behind a menu that is
221
+ still covering it.
222
+ -------------------------------------------------------------------------- */
223
+ const NAV_OPEN_CLASS = 'nav-open';
224
+ const NAV_LOCKED_CLASS = 'nav-locked';
225
+
226
+ function isNavOpen() {
227
+ return document.documentElement.classList.contains(NAV_OPEN_CLASS);
228
+ }
229
+
230
+ function setNavOpen(open) {
231
+ document.documentElement.classList.toggle(NAV_OPEN_CLASS, open);
232
+ document.body.classList.toggle(NAV_LOCKED_CLASS, open);
233
+ const toggle = document.getElementById('sidebarToggle');
234
+ if (toggle) {
235
+ toggle.setAttribute('aria-expanded', String(open));
236
+ }
237
+ }
238
+
239
+ function openNav() {
240
+ setNavOpen(true);
241
+ }
242
+
243
+ function closeNav() {
244
+ if (isNavOpen()) setNavOpen(false);
245
+ }
246
+
247
+ function toggleNav() {
248
+ setNavOpen(!isNavOpen());
249
+ }
250
+
251
+ function initNavDrawer() {
252
+ const toggle = document.getElementById('sidebarToggle');
253
+ if (toggle) {
254
+ toggle.addEventListener('click', toggleNav);
255
+ }
256
+
257
+ const backdrop = document.getElementById('nav-backdrop');
258
+ if (backdrop) {
259
+ backdrop.addEventListener('click', closeNav);
260
+ }
261
+
262
+ document.addEventListener('keydown', event => {
263
+ if (event.key === 'Escape') closeNav();
264
+ });
265
+
266
+ // Crossing the breakpoint either way leaves the drawer closed: open is a
267
+ // state of the narrow layout only, and carrying it into the wide one would
268
+ // leave the backdrop covering a page that has its sidebar back.
269
+ AppEventTarget.addEventListener(LAYOUT_CHANGED_EVENT, closeNav);
270
+ }
271
+
272
+ /* --------------------------------------------------------------------------
273
+ Responsive tables.
274
+
275
+ A section opts in by putting `responsive-table` on the table and nothing
276
+ else: no media query of its own, no card markup, no per-section JS. What the
277
+ helper does depends on how many columns the table has, because the two ways
278
+ out of a narrow screen suit different tables. Up to --table-card-columns the
279
+ row folds into a card, and each cell is labelled with its column heading --
280
+ read from <thead>, so renaming a column renames the label instead of leaving
281
+ a stale one hand-written in the row template. Wider than that, folding
282
+ produces a card nobody can scan, so the table keeps its shape and scrolls
283
+ inside its own container with the first column pinned.
284
+
285
+ Sections rebuild their tables through innerHTML, and often, so this cannot be
286
+ a one-off call at init: initResponsiveTables watches the module container and
287
+ re-runs. Work is skipped where it has already been done, so a re-run over an
288
+ unchanged table costs a query and nothing else.
289
+ -------------------------------------------------------------------------- */
290
+ const TABLE_HELPER_CLASS = 'responsive-table';
291
+ const TABLE_CARD_COLUMNS_FALLBACK = 6;
292
+ const TABLE_ENHANCE_DELAY_MS = 50;
293
+
294
+ let tableEnhanceTimer = null;
295
+
296
+ function readTableCardColumns() {
297
+ const declared = getComputedStyle(document.documentElement)
298
+ .getPropertyValue('--table-card-columns');
299
+ const value = parseInt(declared, 10);
300
+ return Number.isFinite(value) && value > 0 ? value : TABLE_CARD_COLUMNS_FALLBACK;
301
+ }
302
+
303
+ function wrapForHorizontalScroll(table) {
304
+ const parent = table.parentElement;
305
+ if (!parent || parent.classList.contains('table-scroll')) return;
306
+ // Several sections already wrapped their table in an overflow-x container
307
+ // of their own. Adopt it rather than nesting a second scrolling box inside
308
+ // it, which would leave two scrollbars for one table.
309
+ if (parent.classList.contains('overflow-x-auto')) {
310
+ parent.classList.add('table-scroll');
311
+ return;
312
+ }
313
+ const wrapper = document.createElement('div');
314
+ wrapper.className = 'table-scroll';
315
+ parent.insertBefore(wrapper, table);
316
+ wrapper.appendChild(table);
317
+ }
318
+
319
+ function labelCardCells(table, headers) {
320
+ table.querySelectorAll('tbody tr').forEach(row => {
321
+ const cells = row.children;
322
+ // A row that does not line up with the headings is not a data row --
323
+ // an empty-state or a spanning summary row. Labelling it by position
324
+ // would put the wrong heading in front of its text.
325
+ if (cells.length !== headers.length) return;
326
+ Array.from(cells).forEach((cell, index) => {
327
+ if (cell.dataset.label === undefined && headers[index]) {
328
+ cell.dataset.label = headers[index];
329
+ }
330
+ });
331
+ });
332
+ }
333
+
334
+ function enhanceTable(table) {
335
+ const headers = Array.from(table.querySelectorAll('thead th'))
336
+ .map(th => th.textContent.trim());
337
+ if (!headers.length) return;
338
+
339
+ if (headers.length > readTableCardColumns()) {
340
+ table.dataset.responsiveMode = 'scroll';
341
+ wrapForHorizontalScroll(table);
342
+ return;
343
+ }
344
+
345
+ table.dataset.responsiveMode = 'cards';
346
+ labelCardCells(table, headers);
347
+ }
348
+
349
+ function enhanceTables(root) {
350
+ const scope = (root && root.querySelectorAll) ? root : document;
351
+ if (scope.matches && scope.matches(`table.${TABLE_HELPER_CLASS}`)) {
352
+ enhanceTable(scope);
353
+ return;
354
+ }
355
+ scope.querySelectorAll(`table.${TABLE_HELPER_CLASS}`).forEach(enhanceTable);
356
+ }
357
+
358
+ function scheduleTableEnhance() {
359
+ if (tableEnhanceTimer) return;
360
+ tableEnhanceTimer = setTimeout(() => {
361
+ tableEnhanceTimer = null;
362
+ enhanceTables(document);
363
+ }, TABLE_ENHANCE_DELAY_MS);
364
+ }
365
+
366
+ function initResponsiveTables() {
367
+ enhanceTables(document);
368
+
369
+ const container = document.getElementById('modules-container');
370
+ if (!container) return;
371
+
372
+ // childList only: the helper's own writes are attributes, so it does not
373
+ // wake itself. Wrapping a wide table is a child change and costs exactly
374
+ // one extra pass, which then finds the wrapper already there.
375
+ new MutationObserver(scheduleTableEnhance)
376
+ .observe(container, { childList: true, subtree: true });
377
+ }
378
+
379
+ // The one surface sections are meant to use. Everything else about the layout
380
+ // is CSS matching on .is-narrow.
381
+ window.AdminLayout = {
382
+ NARROW_CLASS: LAYOUT_NARROW_CLASS,
383
+ LAYOUT_CHANGED: LAYOUT_CHANGED_EVENT,
384
+ isNarrow: isNarrowLayout,
385
+ breakpoint: readLayoutBreakpoint,
386
+ openNav,
387
+ closeNav,
388
+ toggleNav,
389
+ enhanceTables
390
+ };
391
+
392
+
393
+
394
+
395
+ function toggleSidebar() {
396
+ // In the narrow layout the panel is a drawer, so the chevron closes it
397
+ // rather than collapsing it. Collapsing there would both do nothing visible
398
+ // and overwrite the wide layout's saved state from a screen that has no
399
+ // wide layout.
400
+ if (isNarrowLayout()) {
401
+ closeNav();
402
+ return;
403
+ }
404
+
405
+ const sidebar = document.getElementById('sidebar');
406
+
407
+ sidebar.classList.toggle('sidebar-collapsed');
408
+ const isCollapsed = sidebar.classList.contains('sidebar-collapsed');
409
+ localStorage.setItem('sidebarCollapsed', isCollapsed);
410
+ }
411
+
412
+ // Restore menu state on page load
413
+ document.addEventListener('DOMContentLoaded', function() {
414
+ initLayoutBreakpoint();
415
+ applyThemeBranding();
416
+ const sidebar = document.getElementById('sidebar');
417
+ const isCollapsed = localStorage.getItem('sidebarCollapsed') === 'true';
418
+
419
+ if (isCollapsed) {
420
+ sidebar.classList.add('sidebar-collapsed');
421
+ }
422
+ // Add a handler for the menu toggle button
423
+ const toggleButton = document.getElementById('toggleSidebar');
424
+ if (toggleButton) {
425
+ toggleButton.addEventListener('click', toggleSidebar);
426
+ }
427
+
428
+ if (typeof feather !== 'undefined') {
429
+ feather.replace();
430
+ }
431
+ });
432
+
433
+ async function applyThemeBranding() {
434
+ // The markup already carries the name baked in at build time by
435
+ // replace_placeholder.sh. A theme without branding leaves it alone, and so
436
+ // does a failed request -- the corner is never left empty.
437
+ let brand;
438
+ try {
439
+ const response = await fetch('/api/theme/brand');
440
+ if (!response.ok) return;
441
+ brand = await response.json();
442
+ } catch (error) {
443
+ console.warn('Failed to fetch theme brand:', error);
444
+ return;
445
+ }
446
+ if (!brand || (!brand.brand_name && !brand.logo_url)) return;
447
+
448
+ const name = brand.brand_name || document.title;
449
+ if (brand.brand_name) {
450
+ document.title = brand.brand_name;
451
+ }
452
+
453
+ document.querySelectorAll('.logo-text').forEach(element => {
454
+ if (brand.logo_url) {
455
+ const logo = document.createElement('img');
456
+ logo.src = brand.logo_url;
457
+ logo.alt = name;
458
+ logo.className = 'logo-image';
459
+ element.replaceChildren(logo);
460
+ } else {
461
+ element.textContent = brand.brand_name;
462
+ }
463
+ });
464
+ }
465
+
466
+ function refreshEntireUI() {
467
+ if (window.goodsModule) {
468
+ window.goodsModule.refreshEntireUI();
469
+ }
470
+ updateUIAfterAuth();
471
+ feather.replace();
472
+ }
473
+
474
+ function handleNavigation(e) {
475
+ e.preventDefault();
476
+
477
+ if (!checkAuth()) return;
478
+
479
+ document.querySelectorAll('.nav-item').forEach(navItem => {
480
+ navItem.classList.remove('active');
481
+ });
482
+ this.classList.add('active');
483
+
484
+ const targetId = this.dataset.target;
485
+
486
+ showSection(targetId);
487
+ const sectionName = this.querySelector('span').textContent;
488
+ document.getElementById('current-section-title').textContent = sectionName;
489
+
490
+ // IMPORTANT: use appSections to get the route
491
+ const routePath = appSections.getRoutePath(targetId);
492
+ if (routePath) {
493
+ appRouter.navigate(routePath);
494
+ }
495
+
496
+ const sectionInfo = appSections.getSection(targetId);
497
+ if (sectionInfo && sectionInfo.handler) {
498
+ sectionInfo.handler();
499
+ }
500
+
501
+ closeNav();
502
+ }
503
+
504
+ // Central routing system
505
+ const appRouter = {
506
+ routes: new Map(),
507
+ currentRoute: null,
508
+ isNavigating: false, // Flag to prevent recursion
509
+
510
+ registerRoute(path, handler) {
511
+ console.log(`Registering route: ${path}`);
512
+ this.routes.set(path, handler);
513
+ },
514
+
515
+ unregisterRoute(path) {
516
+ this.routes.delete(path);
517
+ },
518
+
519
+ handleUrlChange() {
520
+ if (this.isNavigating) return;
521
+
522
+ const currentPath = window.location.pathname;
523
+ console.log('Router: URL changed to', currentPath);
524
+ console.log('Available routes:', Array.from(this.routes.keys()));
525
+
526
+ if (this.routes.has(currentPath)) {
527
+ const handler = this.routes.get(currentPath);
528
+ console.log('Router: Found exact match, calling handler');
529
+ this.isNavigating = true;
530
+ try {
531
+ handler();
532
+ this.currentRoute = currentPath;
533
+ } finally {
534
+ setTimeout(() => {
535
+ this.isNavigating = false;
536
+ }, 100);
537
+ }
538
+ return;
539
+ }
540
+
541
+ let matched = false;
542
+ for (const [route, handler] of this.routes) {
543
+ if (currentPath.startsWith(route)) {
544
+ console.log('Router: Found partial match', route);
545
+ this.isNavigating = true;
546
+ try {
547
+ handler();
548
+ this.currentRoute = currentPath;
549
+ matched = true;
550
+ } finally {
551
+ setTimeout(() => {
552
+ this.isNavigating = false;
553
+ }, 100);
554
+ }
555
+ break;
556
+ }
557
+ }
558
+
559
+ if (!matched) {
560
+ console.log('Router: No route found for', currentPath, 'redirecting to /selfcare');
561
+ if (currentPath.includes('/modules/')) {
562
+ console.log('Router: Module route not yet registered, waiting...');
563
+ setTimeout(() => {
564
+ this.handleUrlChange();
565
+ }, 1000);
566
+ } else {
567
+ this.navigate('/selfcare');
568
+ }
569
+ }
570
+ },
571
+
572
+ navigate(path) {
573
+ if (this.isNavigating) {
574
+ console.log('Router: Navigation already in progress, skipping');
575
+ return;
576
+ }
577
+
578
+ if (window.location.pathname === path) {
579
+ console.log('Router: Already on target path, skipping');
580
+ return;
581
+ }
582
+
583
+ this.isNavigating = true;
584
+ try {
585
+ window.history.pushState({}, '', path);
586
+ this.handleUrlChange();
587
+ } finally {
588
+ setTimeout(() => {
589
+ this.isNavigating = false;
590
+ }, 100);
591
+ }
592
+ },
593
+
594
+ getCurrentRoute() {
595
+ return this.currentRoute;
596
+ },
597
+
598
+ getRoutes() {
599
+ return Array.from(this.routes.keys());
600
+ }
601
+ };
602
+
603
+ // Make it globally accessible for modules
604
+ window.appRouter = appRouter;
605
+
606
+ function setupRouter() {
607
+ appRouter.registerRoute('/selfcare/modules/goods', () => {
608
+ console.log('Router: Handling goods route');
609
+ showGoodsSection();
610
+ });
611
+
612
+ appRouter.registerRoute('/selfcare', () => {
613
+ console.log('Router: Handling main selfcare route');
614
+ const defaultSection = document.querySelector('.nav-item');
615
+ if (defaultSection) {
616
+ defaultSection.click();
617
+ }
618
+ });
619
+
620
+ appRouter.registerRoute('/', () => {
621
+ appRouter.navigate('/selfcare');
622
+ });
623
+
624
+ appRouter.registerRoute('/selfcare/modules/', () => {
625
+ // This route will be overridden by modules
626
+ });
627
+
628
+ window.addEventListener('popstate', function(event) {
629
+ appRouter.handleUrlChange();
630
+ });
631
+
632
+ document.addEventListener('click', function(e) {
633
+ const link = e.target.closest('a');
634
+ if (link && link.href) {
635
+ const url = new URL(link.href);
636
+ const path = url.pathname;
637
+
638
+ if (path.startsWith('/selfcare') || path === '/') {
639
+ e.preventDefault();
640
+ appRouter.navigate(path);
641
+ }
642
+ }
643
+ });
644
+ }
645
+
646
+ function initializeApp() {
647
+ const contentSections = document.querySelectorAll('.content-section');
648
+ contentSections.forEach(section => {
649
+ section.classList.remove('active');
650
+ });
651
+
652
+ // loadFilterState();
653
+
654
+ initNavDrawer();
655
+ initResponsiveTables();
656
+
657
+ feather.replace();
658
+
659
+ if (typeof AOS !== 'undefined') {
660
+ AOS.init();
661
+ }
662
+
663
+ setupRouter();
664
+ registerAppSections();
665
+ setupNavigation();
666
+ setupAuthForms();
667
+
668
+ // A token left from before the cookie is traded for one and forgotten; with
669
+ // no token at all the cookie may still hold a session, so it is asked too.
670
+ adoptSession().then(async isValid => {
671
+ if (isValid && !(await ensureAgreements())) {
672
+ return;
673
+ }
674
+ if (isValid) {
675
+ loadModules().then(() => {
676
+ updateVersionPanelVisibility();
677
+ appRouter.handleUrlChange();
678
+
679
+ setTimeout(() => {
680
+ const activeSection = document.querySelector('.content-section.active');
681
+ if (!activeSection) {
682
+ const defaultNav = document.querySelector('.nav-item');
683
+ if (defaultNav) {
684
+ defaultNav.click();
685
+ }
686
+ }
687
+ }, 200);
688
+ });
689
+ } else {
690
+ showAuthModal();
691
+ }
692
+ });
693
+ }
694
+
695
+ /* ==========================================================================
696
+ Platform documents.
697
+
698
+ The panel does not open until the person has accepted the current version of
699
+ every document required of them; the server refuses the sections' requests
700
+ meanwhile (403 agreements_required). Texts and versions come from the server,
701
+ and what is sent back is exactly the versions that were shown.
702
+ ========================================================================== */
703
+ function pendingDocuments(overview) {
704
+ const pending = new Set((overview && overview.pending) || []);
705
+ return ((overview && overview.documents) || []).filter(doc => pending.has(doc.id));
706
+ }
707
+
708
+ function agreementAcceptance(documents) {
709
+ return { documents: (documents || []).map(doc => ({ id: doc.id, version: doc.version })) };
710
+ }
711
+ // end of platform documents helpers
712
+
713
+ async function documentsPending() {
714
+ // The framework does not ship documents and declares no routes for them:
715
+ // those belong to whichever application actually has the documents. What
716
+ // the framework does say is whether this person has anything to accept;
717
+ // without that check the shell used to call /api/agreements for everyone,
718
+ // and in an application with no documents that was a refusal on every
719
+ // sign-in.
720
+ try {
721
+ const response = await fetch('/api/auth/me');
722
+ if (!response.ok) {
723
+ return false;
724
+ }
725
+ return Boolean((await response.json()).documents_pending);
726
+ } catch (error) {
727
+ console.warn('Could not tell whether documents are pending:', error);
728
+ return false;
729
+ }
730
+ }
731
+
732
+ async function ensureAgreements() {
733
+ if (!(await documentsPending())) {
734
+ return true;
735
+ }
736
+ let overview;
737
+ try {
738
+ const response = await fetch('/api/agreements');
739
+ if (!response.ok) {
740
+ return true;
741
+ }
742
+ overview = await response.json();
743
+ } catch (error) {
744
+ console.warn('Could not read the platform documents:', error);
745
+ return true;
746
+ }
747
+ const documents = pendingDocuments(overview);
748
+ if (!documents.length) {
749
+ return true;
750
+ }
751
+ return showAgreementsDialog(documents);
752
+ }
753
+
754
+ function showAgreementsDialog(documents) {
755
+ return new Promise(resolve => {
756
+ const overlay = document.createElement('div');
757
+ overlay.className = 'fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-60';
758
+ overlay.setAttribute('role', 'dialog');
759
+ overlay.setAttribute('aria-modal', 'true');
760
+
761
+ const panel = document.createElement('div');
762
+ panel.className = 'bg-white text-gray-900 rounded-lg shadow-xl w-full max-w-3xl flex flex-col';
763
+ panel.style.maxHeight = '90vh';
764
+
765
+ const header = document.createElement('div');
766
+ header.className = 'p-4 border-b';
767
+ const title = document.createElement('h2');
768
+ title.className = 'text-lg font-semibold';
769
+ title.textContent = 'Before you continue';
770
+ const lead = document.createElement('p');
771
+ lead.className = 'text-sm text-gray-600 mt-1';
772
+ lead.textContent = 'Please read and accept the documents below. You accept these versions; '
773
+ + 'a new version will be shown to you again.';
774
+ header.append(title, lead);
775
+
776
+ const body = document.createElement('div');
777
+ body.className = 'p-4 overflow-y-auto';
778
+ documents.forEach(doc => {
779
+ const heading = document.createElement('h3');
780
+ heading.className = 'font-semibold mt-2';
781
+ heading.textContent = doc.title + ' (version ' + doc.version + ')';
782
+ const text = document.createElement('pre');
783
+ text.className = 'whitespace-pre-wrap text-sm bg-gray-50 border rounded p-3 mt-2 mb-4';
784
+ text.style.fontFamily = 'inherit';
785
+ text.textContent = doc.text || '';
786
+ body.append(heading, text);
787
+ });
788
+
789
+ const footer = document.createElement('div');
790
+ footer.className = 'p-4 border-t flex flex-col gap-3';
791
+ const agree = document.createElement('label');
792
+ agree.className = 'flex items-start gap-2 text-sm';
793
+ const box = document.createElement('input');
794
+ box.type = 'checkbox';
795
+ box.className = 'mt-1';
796
+ const agreeText = document.createElement('span');
797
+ agreeText.textContent = 'I have read and accept ' + documents.map(doc => doc.title).join(', ') + '.';
798
+ agree.append(box, agreeText);
799
+ const error = document.createElement('p');
800
+ error.className = 'text-sm text-red-600';
801
+ error.hidden = true;
802
+ const buttons = document.createElement('div');
803
+ buttons.className = 'flex flex-col md:flex-row gap-2 md:justify-end';
804
+ const leave = document.createElement('button');
805
+ leave.type = 'button';
806
+ leave.className = 'px-4 py-2 rounded border';
807
+ leave.textContent = 'Sign out';
808
+ const accept = document.createElement('button');
809
+ accept.type = 'button';
810
+ accept.className = 'px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-50';
811
+ accept.textContent = 'Accept and continue';
812
+ accept.disabled = true;
813
+ box.addEventListener('change', () => { accept.disabled = !box.checked; });
814
+ buttons.append(leave, accept);
815
+ footer.append(agree, error, buttons);
816
+
817
+ panel.append(header, body, footer);
818
+ overlay.appendChild(panel);
819
+ document.body.appendChild(overlay);
820
+
821
+ leave.addEventListener('click', async () => {
822
+ overlay.remove();
823
+ await logout();
824
+ resolve(false);
825
+ });
826
+ accept.addEventListener('click', async () => {
827
+ accept.disabled = true;
828
+ error.hidden = true;
829
+ try {
830
+ const response = await fetch('/api/agreements/accept', {
831
+ method: 'POST',
832
+ headers: { 'Content-Type': 'application/json' },
833
+ body: JSON.stringify(agreementAcceptance(documents))
834
+ });
835
+ const data = await response.json().catch(() => ({}));
836
+ if (!response.ok) {
837
+ // 409: a document changed while the dialog was open; show the new text.
838
+ error.textContent = data.detail || ('Request failed (' + response.status + ')');
839
+ error.hidden = false;
840
+ if (response.status === 409) {
841
+ overlay.remove();
842
+ resolve(await ensureAgreements());
843
+ return;
844
+ }
845
+ accept.disabled = !box.checked;
846
+ return;
847
+ }
848
+ overlay.remove();
849
+ resolve(true);
850
+ } catch (failure) {
851
+ error.textContent = 'Could not reach the server: ' + failure.message;
852
+ error.hidden = false;
853
+ accept.disabled = !box.checked;
854
+ }
855
+ });
856
+ });
857
+ }
858
+
859
+ async function adoptSession() {
860
+ const stored = localStorage.getItem('authToken');
861
+ if (stored && stored !== SESSION_MARKER) {
862
+ try {
863
+ await fetch('/api/auth/session', {
864
+ method: 'POST',
865
+ headers: { 'Authorization': `Bearer ${stored}` }
866
+ });
867
+ } catch (error) {
868
+ console.warn('Could not move the session into a cookie:', error);
869
+ }
870
+ localStorage.removeItem('authToken');
871
+ }
872
+ return verifyToken();
873
+ }
874
+
875
+ function showSection(sectionId) {
876
+ const contentSections = document.querySelectorAll('.content-section');
877
+ contentSections.forEach(section => {
878
+ section.classList.remove('active');
879
+ });
880
+
881
+ const targetSection = document.getElementById(sectionId);
882
+ if (targetSection) {
883
+ targetSection.classList.add('active');
884
+ }
885
+ }
886
+
887
+ function openGoodsModule() {
888
+ appRouter.navigate('/selfcare/modules/goods');
889
+ }
890
+
891
+ window.openGoodsModule = openGoodsModule;
892
+
893
+ async function loadModules() {
894
+ try {
895
+ const token = localStorage.getItem('authToken');
896
+ const response = await fetch('/api/modules', {
897
+ headers: {
898
+ 'Authorization': `Bearer ${token}`
899
+ }
900
+ });
901
+
902
+ if (response.ok) {
903
+ const data = await response.json();
904
+ await initializeModules(data.modules);
905
+ return data.modules; // Return the modules
906
+ }
907
+ return [];
908
+ } catch (error) {
909
+ return [];
910
+ }
911
+ }
912
+
913
+ const AppEventTarget = new EventTarget();
914
+ const MODULES_LOADED_EVENT = 'app:modules:loaded';
915
+
916
+ window.AppEventTarget = AppEventTarget;
917
+ window.AppEvents = {
918
+ MODULES_LOADED: MODULES_LOADED_EVENT,
919
+ AUTH_CHANGED: 'app:auth:changed',
920
+ ROUTE_CHANGED: 'app:route:changed'
921
+ };
922
+
923
+ async function initializeModules(modules) {
924
+ if (!modules || modules.length === 0) {
925
+ console.log('No modules available to load');
926
+ return;
927
+ }
928
+
929
+ loadedModules = modules;
930
+
931
+ console.log('Loading modules:', modules);
932
+
933
+ try {
934
+ // First load the CSS
935
+ modules.forEach(module => {
936
+ if (module.css) {
937
+ const link = document.createElement('link');
938
+ link.rel = 'stylesheet';
939
+ link.href = moduleAssetUrl(module.css, module.version);
940
+ document.head.appendChild(link);
941
+ }
942
+ });
943
+
944
+ // Then load the modules' JS sequentially
945
+ for (const module of modules) {
946
+ if (module.js) {
947
+ try {
948
+ await loadScript(moduleAssetUrl(module.js, module.version));
949
+ console.log('Module loaded:', module.js);
950
+ } catch (error) {
951
+ console.error('Failed to load JS module:', module.js, error);
952
+ }
953
+ }
954
+ }
955
+
956
+ // Initialize modules and register their routes
957
+ modules.forEach(module => {
958
+ if (module.initFunction && typeof window[module.initFunction] === 'function') {
959
+ try {
960
+ console.log('Initializing module:', module.initFunction);
961
+ window[module.initFunction]();
962
+
963
+ // Register the module's route if specified
964
+ if (module.routePath && module.sectionId) {
965
+ console.log(`Registering module route: ${module.routePath} -> ${module.sectionId}`);
966
+ appRouter.registerRoute(module.routePath, () => {
967
+ console.log(`Router: Handling module route ${module.routePath}`);
968
+ if (window[module.handlerFunction]) {
969
+ window[module.handlerFunction]();
970
+ }
971
+ });
972
+ }
973
+ } catch (error) {
974
+ console.error('Failed to initialize module:', module.initFunction, error);
975
+ }
976
+ } else {
977
+ console.warn('Initialization function not found:', module.initFunction);
978
+ }
979
+ });
980
+ const event = new CustomEvent(MODULES_LOADED_EVENT, {
981
+ detail: {
982
+ modules: loadedModules,
983
+ count: loadedModules.length,
984
+ timestamp: new Date().toISOString()
985
+ },
986
+ bubbles: false,
987
+ cancelable: true
988
+ });
989
+
990
+ AppEventTarget.dispatchEvent(event);
991
+ window.frontendModulesloaded = true;
992
+ } catch (error) {
993
+ console.error('Failed to initialize modules:', error);
994
+ }
995
+ }
996
+
997
+ function handleRouteAfterModulesLoaded() {
998
+ const currentPath = window.location.pathname;
999
+
1000
+ setTimeout(() => {
1001
+ appRouter.handleUrlChange();
1002
+ }, 100);
1003
+ }
1004
+
1005
+ // Module version in its file addresses.
1006
+ //
1007
+ // The browser caches module.css and module.js under the usual rules, and
1008
+ // after editing a module the user kept seeing the old version until a hard
1009
+ // reload: CSS from one version with a script from another produced, for
1010
+ // example, buttons under the canvas. The version from the module catalogue
1011
+ // makes the address new as soon as the module is updated.
1012
+ function moduleAssetUrl(path, version) {
1013
+ if (!path || !version) return path;
1014
+ return path + (path.includes('?') ? '&' : '?') + 'v=' + encodeURIComponent(version);
1015
+ }
1016
+
1017
+ function loadScript(src) {
1018
+ return new Promise((resolve, reject) => {
1019
+ const script = document.createElement('script');
1020
+ script.src = src;
1021
+ script.onload = resolve;
1022
+ script.onerror = reject;
1023
+ document.head.appendChild(script);
1024
+ });
1025
+ }
1026
+
1027
+ function updateNavigationWithModules() {
1028
+ const container = document.getElementById('modules-nav-container');
1029
+ if (!container) {
1030
+ return;
1031
+ }
1032
+
1033
+ container.innerHTML = '';
1034
+
1035
+ loadedModules.forEach(module => {
1036
+ const navItem = document.createElement('a');
1037
+ navItem.href = '#';
1038
+ navItem.className = 'nav-item';
1039
+ navItem.setAttribute('data-target', `${module.id}-section`);
1040
+
1041
+ navItem.innerHTML = `
1042
+ <i data-feather="users" class="w-5 h-5"></i>
1043
+ <span class="nav-text">${module.name}</span>
1044
+ `;
1045
+
1046
+ container.appendChild(navItem);
1047
+ });
1048
+
1049
+ setupNavigation();
1050
+ feather.replace();
1051
+ }
1052
+
1053
+ // Authentication check
1054
+ function checkAuth() {
1055
+ const token = localStorage.getItem('authToken');
1056
+ if (!token) {
1057
+ showAuthModal();
1058
+ return false;
1059
+ }
1060
+ verifyToken();
1061
+ return true;
1062
+ }
1063
+
1064
+ function showAuthModal() {
1065
+ const authBlock = document.getElementById('authBlock');
1066
+ authBlock.classList.remove('hidden');
1067
+
1068
+ document.getElementById('loginTabContent').classList.add('active');
1069
+ document.getElementById('registerTabContent').classList.remove('active');
1070
+ document.getElementById('loginTab').classList.add('border-blue-600', 'text-blue-600');
1071
+ document.getElementById('registerTab').classList.remove('border-blue-600', 'text-blue-600');
1072
+
1073
+ applyPublicConfig();
1074
+ }
1075
+
1076
+ // What this deployment offers before anybody has signed in: whether accounts
1077
+ // can be created here at all, and the terms a person is asked to agree to.
1078
+ let publicConfigPromise = null;
1079
+
1080
+ function loadPublicConfig() {
1081
+ if (!publicConfigPromise) {
1082
+ publicConfigPromise = fetch('/api/public/config')
1083
+ .then(response => (response.ok ? response.json() : null))
1084
+ .catch(() => null);
1085
+ }
1086
+ return publicConfigPromise;
1087
+ }
1088
+
1089
+ async function applyPublicConfig() {
1090
+ const config = await loadPublicConfig();
1091
+ const registerTab = document.getElementById('registerTab');
1092
+
1093
+ // Registration closed is the default, so the tab stays out of sight unless
1094
+ // the server says otherwise: a form that creates accounts nobody activates
1095
+ // contradicts the landing page, which sends people to the bot.
1096
+ if (registerTab) {
1097
+ registerTab.hidden = !(config && config.self_registration);
1098
+ }
1099
+
1100
+ const terms = document.getElementById('termsContent');
1101
+ if (terms && config && config.terms) {
1102
+ terms.textContent = config.terms;
1103
+ terms.classList.add('terms-plain');
1104
+ }
1105
+ }
1106
+
1107
+ function closeAuthModal() {
1108
+ const authBlock = document.getElementById('authBlock');
1109
+ authBlock.classList.add('hidden');
1110
+ }
1111
+
1112
+ function setupNavigation() {
1113
+ const navItems = document.querySelectorAll('.nav-item');
1114
+ navItems.forEach(item => {
1115
+ item.removeEventListener('click', handleNavigation);
1116
+ });
1117
+
1118
+ navItems.forEach(item => {
1119
+ item.addEventListener('click', handleNavigation);
1120
+ });
1121
+
1122
+ document.addEventListener('click', function(e) {
1123
+ if (e.target.closest('.nav-item')) {
1124
+ handleNavigation.call(e.target.closest('.nav-item'), e);
1125
+ }
1126
+ });
1127
+ }
1128
+
1129
+ const appSections = {
1130
+ sections: new Map(),
1131
+
1132
+ registerSection(targetId, routePath, handler) {
1133
+ this.sections.set(targetId, { routePath, handler });
1134
+
1135
+ if (window.appRouter) {
1136
+ window.appRouter.registerRoute(routePath, handler);
1137
+ }
1138
+ },
1139
+
1140
+ getSection(targetId) {
1141
+ return this.sections.get(targetId);
1142
+ },
1143
+
1144
+ getRoutePath(targetId) {
1145
+ const section = this.sections.get(targetId);
1146
+ return section ? section.routePath : '/selfcare';
1147
+ },
1148
+
1149
+ getSections() {
1150
+ return Array.from(this.sections.keys());
1151
+ }
1152
+ };
1153
+
1154
+ window.appSections = appSections;
1155
+
1156
+ function setupAuthForms() {
1157
+ const loginTab = document.getElementById('loginTab');
1158
+ const registerTab = document.getElementById('registerTab');
1159
+ const loginTabContent = document.getElementById('loginTabContent');
1160
+ const registerTabContent = document.getElementById('registerTabContent');
1161
+
1162
+ if (loginTab && registerTab) {
1163
+ loginTab.addEventListener('click', function() {
1164
+ loginTab.classList.add('border-blue-600', 'text-blue-600');
1165
+ registerTab.classList.remove('border-blue-600', 'text-blue-600');
1166
+ registerTab.classList.add('text-gray-500');
1167
+
1168
+ loginTabContent.classList.add('active');
1169
+ registerTabContent.classList.remove('active');
1170
+ });
1171
+
1172
+ registerTab.addEventListener('click', function() {
1173
+ registerTab.classList.add('border-blue-600', 'text-blue-600');
1174
+ loginTab.classList.remove('border-blue-600', 'text-blue-600');
1175
+ loginTab.classList.add('text-gray-500');
1176
+
1177
+ registerTabContent.classList.add('active');
1178
+ loginTabContent.classList.remove('active');
1179
+ });
1180
+ }
1181
+
1182
+ // Forgot password (task 272): the link leads to the bot, and the reset is
1183
+ // issued by it. The bot's name comes from the server -- it differs between
1184
+ // the stand and production, and hardcoding it into the markup would mean
1185
+ // keeping two truths. If the server doesn't answer, the link stays a hint
1186
+ // with no destination, rather than a broken address.
1187
+ applyForgotPasswordLink();
1188
+
1189
+ // Updated sign-in form handler
1190
+ document.getElementById('loginForm').addEventListener('submit', function (e) {
1191
+ e.preventDefault();
1192
+ const username = document.getElementById('loginUsername').value;
1193
+ const password = document.getElementById('loginPassword').value;
1194
+ login(username, password);
1195
+ });
1196
+
1197
+ document.getElementById('registerForm').addEventListener('submit', function (e) {
1198
+ e.preventDefault();
1199
+ const username = document.getElementById('registerUsername').value;
1200
+ const password = document.getElementById('registerPassword').value;
1201
+ const passwordConfirm = document.getElementById('registerPasswordConfirm').value;
1202
+ const email = document.getElementById('registerEmail').value;
1203
+ const phone = document.getElementById('registerPhone').value;
1204
+ const fullName = document.getElementById('registerFullName').value;
1205
+ const agreeTerms = document.getElementById('registerAgreeTerms').checked;
1206
+
1207
+ if (password !== passwordConfirm) {
1208
+ showAuthMessage('Passwords do not match', 'error');
1209
+ return;
1210
+ }
1211
+
1212
+ if (!agreeTerms) {
1213
+ showAuthMessage('You must agree to the terms of use', 'error');
1214
+ return;
1215
+ }
1216
+
1217
+ register(username, password, email, phone, fullName, agreeTerms);
1218
+ });
1219
+ }
1220
+
1221
+ function registerAppSections() {
1222
+
1223
+ appSections.registerSection(
1224
+ 'upload-section',
1225
+ '/selfcare/upload',
1226
+ () => {
1227
+ showSection('upload-section');
1228
+ loadXmlSources();
1229
+ }
1230
+ );
1231
+
1232
+ appSections.registerSection(
1233
+ 'dashboard-section',
1234
+ '/selfcare',
1235
+ () => {
1236
+ showSection('dashboard-section');
1237
+ }
1238
+ );
1239
+ }
1240
+
1241
+ // The token lives a day and expires silently: without this the panel simply
1242
+ // starts getting refusals in the middle of the work. The server tells how long
1243
+ // it has at login, and the session is exchanged an hour before the end -- while
1244
+ // the token is still valid, which is the only time the exchange is allowed.
1245
+ const SESSION_REFRESH_LEAD_SECONDS = 3600;
1246
+ let sessionRefreshTimer = null;
1247
+
1248
+ function scheduleSessionRefresh(expiresInSeconds) {
1249
+ if (sessionRefreshTimer) {
1250
+ clearTimeout(sessionRefreshTimer);
1251
+ sessionRefreshTimer = null;
1252
+ }
1253
+ if (!expiresInSeconds) {
1254
+ return;
1255
+ }
1256
+ // Never sooner than a minute: a token that short is about to be replaced
1257
+ // by a login anyway, and a tight timer would spin.
1258
+ const delay = Math.max(60, expiresInSeconds - SESSION_REFRESH_LEAD_SECONDS);
1259
+ sessionRefreshTimer = setTimeout(refreshSession, delay * 1000);
1260
+ }
1261
+
1262
+ async function refreshSession() {
1263
+ if (!localStorage.getItem('authToken')) {
1264
+ return;
1265
+ }
1266
+ try {
1267
+ const response = await fetch('/api/auth/refresh', { method: 'POST' });
1268
+ if (!response.ok) {
1269
+ // Nothing to do about it here: the next request will be refused and
1270
+ // the panel asks for a login as before.
1271
+ console.warn('Failed to refresh session:', response.status);
1272
+ return;
1273
+ }
1274
+ const data = await response.json();
1275
+ if (data.access_token) {
1276
+ // The renewed session is already in the cookie; the body's token
1277
+ // is for agents and is not kept.
1278
+ scheduleSessionRefresh(data.expires_in);
1279
+ }
1280
+ } catch (error) {
1281
+ console.warn('Failed to refresh session:', error);
1282
+ }
1283
+ }
1284
+
1285
+ async function login(username, password) {
1286
+ try {
1287
+ const response = await fetch('/api/auth/login', {
1288
+ method: 'POST',
1289
+ headers: {
1290
+ 'Content-Type': 'application/json'
1291
+ },
1292
+ body: JSON.stringify({ username, password })
1293
+ });
1294
+
1295
+ const data = await response.json();
1296
+
1297
+ if (response.ok) {
1298
+ localStorage.setItem('authToken', SESSION_MARKER);
1299
+ scheduleSessionRefresh(data.expires_in);
1300
+ const userResponse = await fetch('/api/auth/me');
1301
+
1302
+ if (userResponse.ok) {
1303
+ const userData = await userResponse.json();
1304
+ currentUser = userData;
1305
+ updateUIAfterAuth();
1306
+ closeAuthModal();
1307
+ if (!(await ensureAgreements())) {
1308
+ return;
1309
+ }
1310
+ await loadModules();
1311
+ refreshEntireUI();
1312
+
1313
+ closeAuthModal();
1314
+ showAuthMessage('Signed in successfully!', 'success');
1315
+ } else {
1316
+ showAuthMessage('Failed to get user data', 'error');
1317
+ }
1318
+ } else {
1319
+ showAuthMessage(data.detail || 'Sign-in failed', 'error');
1320
+ }
1321
+ } catch (error) {
1322
+ showAuthMessage('Network error: ' + error, 'error');
1323
+ }
1324
+ }
1325
+
1326
+ function togglePasswordVisibility(inputId) {
1327
+ const passwordInput = document.getElementById(inputId);
1328
+ const toggleButton = document.getElementById(inputId + 'Toggle');
1329
+
1330
+ if (passwordInput.type === 'password') {
1331
+ passwordInput.type = 'text';
1332
+ toggleButton.innerHTML = '<i data-feather="eye-off" class="w-4 h-4"></i>';
1333
+ } else {
1334
+ passwordInput.type = 'password';
1335
+ toggleButton.innerHTML = '<i data-feather="eye" class="w-4 h-4"></i>';
1336
+ }
1337
+ feather.replace();
1338
+ }
1339
+
1340
+
1341
+ async function register(username, password, email = null, phone = null, fullName = null, agreeTerms = false) {
1342
+ try {
1343
+ const registerData = {
1344
+ username: username.trim(),
1345
+ password: password,
1346
+ agree_terms: agreeTerms
1347
+ };
1348
+
1349
+ // Add optional fields only if present
1350
+ if (email && email.trim()) {
1351
+ registerData.email = email.trim();
1352
+ }
1353
+
1354
+ if (phone && phone.trim()) {
1355
+ // Strip formatting from the phone number
1356
+ const cleanedPhone = phone.replace(/\D/g, '');
1357
+ if (cleanedPhone) {
1358
+ registerData.phone = cleanedPhone;
1359
+ }
1360
+ }
1361
+
1362
+ if (fullName && fullName.trim()) {
1363
+ registerData.full_name = fullName.trim();
1364
+ }
1365
+
1366
+ console.log('Registration data being sent:', registerData);
1367
+
1368
+ const response = await fetch('/api/auth/register', {
1369
+ method: 'POST',
1370
+ headers: {
1371
+ 'Content-Type': 'application/json'
1372
+ },
1373
+ body: JSON.stringify(registerData)
1374
+ });
1375
+
1376
+ const responseText = await response.text();
1377
+ console.log('Server response (text):', responseText);
1378
+
1379
+ let data;
1380
+ try {
1381
+ data = JSON.parse(responseText);
1382
+ } catch (e) {
1383
+ console.error('Failed to parse JSON response:', e);
1384
+ showAuthMessage('Server error: invalid response format', 'error');
1385
+ return;
1386
+ }
1387
+
1388
+ if (response.status === 422) {
1389
+ // Handle validation errors
1390
+ const errorMessage = handleValidationError(data);
1391
+ showAuthMessage(`Error: ${errorMessage}. Please check the entered data.`, 'error');
1392
+ return;
1393
+ }
1394
+
1395
+ if (response.ok && data.success) {
1396
+ showAuthMessage('Registration successful! Wait for administrator activation.', 'success');
1397
+
1398
+ // Reset the registration form
1399
+ resetRegistrationForm();
1400
+
1401
+ // Switch to the sign-in tab after 2 seconds
1402
+ setTimeout(() => {
1403
+ document.getElementById('loginTab').click();
1404
+ }, 2000);
1405
+ } else {
1406
+ // Generic handling of other errors
1407
+ const errorMsg = data.detail || data.message || `Registration error (status: ${response.status})`;
1408
+ showAuthMessage(errorMsg, 'error');
1409
+ }
1410
+ } catch (error) {
1411
+ console.error('Registration error:', error);
1412
+ showAuthMessage('Network error: ' + error.message, 'error');
1413
+ }
1414
+ }
1415
+
1416
+ function handleValidationError(data) {
1417
+ if (!data || !data.detail) {
1418
+ return 'Unknown validation error';
1419
+ }
1420
+
1421
+ const detail = data.detail;
1422
+
1423
+ // If this is an array of errors (FastAPI's standard 422 format)
1424
+ if (Array.isArray(detail)) {
1425
+ const messages = [];
1426
+
1427
+ detail.forEach(err => {
1428
+ let fieldName = 'unknown field';
1429
+ let errorMsg = err.msg || 'Error';
1430
+
1431
+ // Extract the field name from loc
1432
+ if (err.loc && err.loc.length > 1) {
1433
+ fieldName = err.loc[err.loc.length - 1];
1434
+
1435
+ // Convert field names into a readable form
1436
+ const fieldMap = {
1437
+ 'username': 'Username',
1438
+ 'password': 'Password',
1439
+ 'email': 'Email',
1440
+ 'phone': 'Phone',
1441
+ 'full_name': 'Full name',
1442
+ 'agree_terms': 'Agreement to the terms'
1443
+ };
1444
+
1445
+ fieldName = fieldMap[fieldName] || fieldName;
1446
+ }
1447
+
1448
+ // Convert error messages into a readable form
1449
+ const errorMap = {
1450
+ 'field required': 'required field',
1451
+ 'Invalid email format': 'invalid email format',
1452
+ 'Phone number must be at least 10 digits': 'the number must contain at least 10 digits',
1453
+ 'You must agree to the terms of use': 'you must agree to the terms'
1454
+ };
1455
+
1456
+ errorMsg = errorMap[errorMsg] || errorMsg;
1457
+
1458
+ messages.push(`${fieldName}: ${errorMsg}`);
1459
+ });
1460
+
1461
+ return messages.join('. ');
1462
+ }
1463
+
1464
+ // If this is a string
1465
+ if (typeof detail === 'string') {
1466
+ return detail;
1467
+ }
1468
+
1469
+ // If this is an object
1470
+ if (typeof detail === 'object') {
1471
+ const messages = [];
1472
+ for (const [field, msg] of Object.entries(detail)) {
1473
+ messages.push(`${field}: ${msg}`);
1474
+ }
1475
+ return messages.join('. ');
1476
+ }
1477
+
1478
+ return 'Data validation error';
1479
+ }
1480
+
1481
+
1482
+ // --- The application's badge in the header -----------------------------------
1483
+ //
1484
+ // The top right corner shows the user's role unless the application puts a badge
1485
+ // of its own there -- a client's balance, a plan, a quota. The application calls
1486
+ // AppHeader.setBadge({text, title, tone}) and AppHeader.clearBadge(); the text
1487
+ // is only ever text, and the tone one of a closed list the theme styles. The
1488
+ // badge belongs to the session: signing out takes it away, so the next person on
1489
+ // the same computer does not see the previous one's figure.
1490
+
1491
+ const HEADER_BADGE_TONES = ['neutral', 'positive', 'warning'];
1492
+ let headerBadge = null;
1493
+
1494
+ /** What the corner shows for this user and this badge -- pure, tested under node. */
1495
+ function headerBadgeView(user, badge) {
1496
+ if (badge && badge.text) {
1497
+ const tone = HEADER_BADGE_TONES.includes(badge.tone) ? badge.tone : 'neutral';
1498
+ return { text: String(badge.text), title: badge.title ? String(badge.title) : '',
1499
+ className: `ml-2 role-badge header-badge header-badge-${tone}` };
1500
+ }
1501
+ // The corner answers the same question it always did -- is this an
1502
+ // administrator -- and names the whole set in the tooltip, where a second
1503
+ // role fits and two words in the corner would not.
1504
+ const roles = userRolesOf(user);
1505
+ const admin = roles.indexOf(ROLE_ADMIN) !== -1;
1506
+ return { text: admin ? 'Admin' : 'Client',
1507
+ title: roles.length > 1 ? roles.join(', ') : '',
1508
+ className: `ml-2 role-badge ${admin ? 'role-admin' : 'role-client'}` };
1509
+ }
1510
+
1511
+ function renderHeaderBadge() {
1512
+ const corner = document.getElementById('userRole');
1513
+ if (!corner) return;
1514
+ const view = headerBadgeView(currentUser, headerBadge);
1515
+ corner.textContent = view.text;
1516
+ corner.title = view.title;
1517
+ corner.className = view.className;
1518
+ }
1519
+
1520
+ window.AppHeader = {
1521
+ setBadge(badge) {
1522
+ headerBadge = badge && badge.text ? { text: badge.text, title: badge.title, tone: badge.tone } : null;
1523
+ renderHeaderBadge();
1524
+ },
1525
+ clearBadge() {
1526
+ headerBadge = null;
1527
+ renderHeaderBadge();
1528
+ },
1529
+ };
1530
+
1531
+ // --- end of the application's badge in the header ------------------------------
1532
+
1533
+ async function logout() {
1534
+ // The server ends the session first: forgetting it only in this browser
1535
+ // would leave a copied token working.
1536
+ try {
1537
+ await fetch('/api/auth/logout', { method: 'POST' });
1538
+ } catch (error) {
1539
+ console.warn('Could not end the session on the server:', error);
1540
+ }
1541
+ localStorage.removeItem('authToken');
1542
+ localStorage.removeItem('auth_token');
1543
+ currentUser = null;
1544
+ headerBadge = null;
1545
+ updateUIAfterAuth();
1546
+ showAuthModal();
1547
+ hideVersionPanel();
1548
+ }
1549
+
1550
+ async function verifyToken() {
1551
+ try {
1552
+ const response = await fetch('/api/auth/verify', { method: 'GET' });
1553
+
1554
+ const data = await response.json();
1555
+
1556
+ if (data.success) {
1557
+ localStorage.setItem('authToken', SESSION_MARKER);
1558
+ currentUser = data.user;
1559
+ updateUIAfterAuth();
1560
+ return true;
1561
+ } else {
1562
+ localStorage.removeItem('authToken');
1563
+ currentUser = null;
1564
+ updateUIAfterAuth();
1565
+ return false;
1566
+ }
1567
+ } catch (error) {
1568
+ localStorage.removeItem('authToken');
1569
+ currentUser = null;
1570
+ updateUIAfterAuth();
1571
+ return false;
1572
+ }
1573
+ }
1574
+
1575
+ function updateUIAfterAuth() {
1576
+ const authBlock = document.getElementById('authBlock');
1577
+
1578
+ if (currentUser) {
1579
+ authBlock.classList.add('hidden');
1580
+
1581
+ document.getElementById('usernameDisplay').textContent = currentUser.username;
1582
+
1583
+ // Update the display of optional fields if present
1584
+ if (currentUser.email) {
1585
+ const emailElement = document.getElementById('userEmail');
1586
+ if (emailElement) {
1587
+ emailElement.textContent = currentUser.email;
1588
+ emailElement.parentElement.style.display = 'block';
1589
+ }
1590
+ }
1591
+
1592
+ if (currentUser.phone) {
1593
+ const phoneElement = document.getElementById('userPhone');
1594
+ if (phoneElement) {
1595
+ phoneElement.textContent = currentUser.phone;
1596
+ phoneElement.parentElement.style.display = 'block';
1597
+ }
1598
+ }
1599
+
1600
+ if (currentUser.full_name) {
1601
+ const fullNameElement = document.getElementById('userFullName');
1602
+ if (fullNameElement) {
1603
+ fullNameElement.textContent = currentUser.full_name;
1604
+ fullNameElement.parentElement.style.display = 'block';
1605
+ }
1606
+ }
1607
+
1608
+ const userStatus = document.getElementById('userStatus');
1609
+ userStatus.textContent = currentUser.status === 'active' ? 'Active' : 'Blocked';
1610
+ userStatus.className = `ml-2 status-badge ${currentUser.status === 'active' ? 'status-active' : 'status-blocked'}`;
1611
+
1612
+ // The role, or the application's badge in its place (keepup-37).
1613
+ renderHeaderBadge();
1614
+
1615
+ document.getElementById('userInfo').style.display = 'block';
1616
+
1617
+ const adminItems = document.querySelectorAll('.admin-only');
1618
+ adminItems.forEach(item => {
1619
+ if (currentUserIsAdmin()) {
1620
+ item.classList.remove('hidden');
1621
+ } else {
1622
+ item.classList.add('hidden');
1623
+ }
1624
+ });
1625
+ updateVersionPanelVisibility();
1626
+ } else {
1627
+ authBlock.classList.remove('hidden');
1628
+ document.getElementById('userInfo').style.display = 'none';
1629
+ hideVersionPanel();
1630
+ }
1631
+ }
1632
+
1633
+ function resetRegistrationForm() {
1634
+ const form = document.getElementById('registerForm');
1635
+ if (form) {
1636
+ form.reset();
1637
+ }
1638
+ }
1639
+
1640
+ function showAuthMessage(text, type) {
1641
+ const messageDiv = document.getElementById('authMessage');
1642
+ messageDiv.textContent = text;
1643
+ messageDiv.className = 'mt-4 p-3 rounded-lg text-sm break-words';
1644
+
1645
+ if (type === 'success') {
1646
+ messageDiv.classList.add('bg-green-50', 'text-green-800', 'border', 'border-green-200');
1647
+ } else {
1648
+ messageDiv.classList.add('bg-red-50', 'text-red-800', 'border', 'border-red-200');
1649
+ }
1650
+
1651
+ messageDiv.classList.remove('hidden');
1652
+
1653
+ // Auto-hide after 10 seconds for errors, 5 for success
1654
+ const timeout = type === 'success' ? 5000 : 10000;
1655
+ setTimeout(() => {
1656
+ if (messageDiv.parentElement) {
1657
+ messageDiv.classList.add('hidden');
1658
+ }
1659
+ }, timeout);
1660
+ }
1661
+
1662
+ function findHeaderByText(headerRow, text) {
1663
+ const headers = headerRow.querySelectorAll('th');
1664
+ for (let header of headers) {
1665
+ if (header.textContent.trim() === text) {
1666
+ return header;
1667
+ }
1668
+ }
1669
+ return null;
1670
+ }
1671
+
1672
+ // Variable tracking the collapsed/expanded state
1673
+ let isGlobalDeductionExpanded = true;
1674
+
1675
+ // Initialize on load
1676
+ document.addEventListener('DOMContentLoaded', function() {
1677
+ feather.replace();
1678
+ });
1679
+
1680
+
1681
+ // Initialize the phone mask on load
1682
+ document.addEventListener('DOMContentLoaded', function() {
1683
+ const phoneInput = document.getElementById('registerPhone');
1684
+ if (phoneInput) {
1685
+
1686
+ phoneInput.addEventListener('keydown', function(e) {
1687
+ // Allow: backspace, delete, tab, escape, enter
1688
+ if ([46, 8, 9, 27, 13].indexOf(e.keyCode) !== -1 ||
1689
+ // Allow: Ctrl+A
1690
+ (e.keyCode === 65 && e.ctrlKey === true) ||
1691
+ // Allow: home, end, left, right
1692
+ (e.keyCode >= 35 && e.keyCode <= 39)) {
1693
+ return;
1694
+ }
1695
+
1696
+ // Block everything except digits
1697
+ if ((e.keyCode < 48 || e.keyCode > 57) && (e.keyCode < 96 || e.keyCode > 105)) {
1698
+ e.preventDefault();
1699
+ }
1700
+ });
1701
+ }
1702
+ });
1703
+
1704
+ // Pagination handlers
1705
+ const prevPageBtn = document.getElementById('prevPage');
1706
+ const nextPageBtn = document.getElementById('nextPage');
1707
+
1708
+ if (prevPageBtn) {
1709
+ prevPageBtn.addEventListener('click', () => {
1710
+ if (currentPage > 1) {
1711
+ currentPage--;
1712
+ displayProducts(filteredProducts);
1713
+
1714
+ // Add a small animation
1715
+ prevPageBtn.classList.add('transform', 'scale-95');
1716
+ setTimeout(() => {
1717
+ prevPageBtn.classList.remove('transform', 'scale-95');
1718
+ }, 150);
1719
+ }
1720
+ });
1721
+ }
1722
+
1723
+ if (nextPageBtn) {
1724
+ nextPageBtn.addEventListener('click', () => {
1725
+ const totalPages = Math.ceil(filteredProducts.length / itemsPerPage);
1726
+ if (currentPage < totalPages) {
1727
+ currentPage++;
1728
+ displayProducts(filteredProducts);
1729
+
1730
+ // Add a small animation
1731
+ nextPageBtn.classList.add('transform', 'scale-95');
1732
+ setTimeout(() => {
1733
+ nextPageBtn.classList.remove('transform', 'scale-95');
1734
+ }, 150);
1735
+ }
1736
+ });
1737
+ }
1738
+
1739
+ // Helper function for showing notifications
1740
+ // --- Escaping data for section markup (keepup-62) ---
1741
+ // Sections build their tables from template strings. A value that came from data
1742
+ // -- a name, an id, a message -- goes through keepupEscapeHtml in markup, and
1743
+ // through keepupJsArg when it has to be an argument of an inline handler: there
1744
+ // it is a JavaScript string inside an HTML attribute, where escaping for HTML
1745
+ // alone is not enough.
1746
+ function keepupEscapeHtml(value) {
1747
+ return String(value === undefined || value === null ? '' : value)
1748
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
1749
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
1750
+ }
1751
+
1752
+ function keepupJsArg(value) {
1753
+ return keepupEscapeHtml(JSON.stringify(String(value === undefined || value === null ? '' : value)));
1754
+ }
1755
+ window.keepupEscapeHtml = keepupEscapeHtml;
1756
+ window.keepupJsArg = keepupJsArg;
1757
+ // --- end of escaping data for section markup ---
1758
+
1759
+ function showNotification(message, type = 'info') {
1760
+ const notification = document.createElement('div');
1761
+ // The box the dismiss button removes, named as data rather than found by
1762
+ // walking up from the button: the markup is the section's, the relationship
1763
+ // is the shell's (keepup-93).
1764
+ notification.dataset.notification = '';
1765
+ notification.className = `fixed top-4 right-4 p-4 rounded-lg shadow-lg z-50 ${type === 'success' ? 'bg-green-100 text-green-800 border border-green-200' :
1766
+ type === 'error' ? 'bg-red-100 text-red-800 border border-red-200' :
1767
+ 'bg-blue-100 text-blue-800 border border-blue-200'
1768
+ }`;
1769
+ notification.innerHTML = `
1770
+ <div class="flex items-center">
1771
+ <i data-feather="${type === 'success' ? 'check-circle' : type === 'error' ? 'alert-circle' : 'info'}"
1772
+ class="w-5 h-5 mr-2"></i>
1773
+ <span data-notification-text></span>
1774
+ <button type="button" data-action="shell.dismiss-notification"
1775
+ class="ml-4 text-gray-500 hover:text-gray-700">
1776
+ <i data-feather="x" class="w-4 h-4"></i>
1777
+ </button>
1778
+ </div>
1779
+ `;
1780
+ // The message is text, never markup: it often carries a name that came from
1781
+ // data -- a user's, a section's -- and markup there would run (keepup-62).
1782
+ notification.querySelector('[data-notification-text]').textContent =
1783
+ message === null || message === undefined ? '' : String(message);
1784
+ document.body.appendChild(notification);
1785
+
1786
+ // Refresh feather icons
1787
+ feather.replace();
1788
+
1789
+ // Auto-hide after 5 seconds
1790
+ setTimeout(() => {
1791
+ if (notification.parentElement) {
1792
+ notification.remove();
1793
+ }
1794
+ }, 5000);
1795
+ }
1796
+
1797
+ // The showNotification() notification style is the only thing the shell
1798
+ // declares here. The button rules of one application's section that used to
1799
+ // stand nearby left together with the code that set them: a section carries
1800
+ // its own styles (keepup-27).
1801
+ const additionalStyles = `
1802
+ .notification {
1803
+ transition: all 0.3s ease;
1804
+ }
1805
+ `;
1806
+
1807
+ // Add the styles to the document
1808
+ const styleSheet = document.createElement('style');
1809
+ styleSheet.textContent = additionalStyles;
1810
+ document.head.appendChild(styleSheet);
1811
+
1812
+
1813
+ document.addEventListener('DOMContentLoaded', function() {
1814
+ initializeApp();
1815
+ checkAuth();
1816
+ });
1817
+
1818
+
1819
+ // Messages from other windows (keepup-77). Only this page's own origin is
1820
+ // heard: any site the user has open can post to this window, and the handler
1821
+ // used to act on whatever arrived. What is heard is handed on as a
1822
+ // `keepup:message` event, which a section listens to for the messages that are
1823
+ // its business -- the shell has no product logic of its own to run.
1824
+ function keepupHandleWindowMessage(event) {
1825
+ if (event.origin !== window.location.origin) {
1826
+ return;
1827
+ }
1828
+ const data = event.data;
1829
+ if (!data || typeof data !== 'object' || typeof data.type !== 'string') {
1830
+ return;
1831
+ }
1832
+ window.dispatchEvent(new CustomEvent('keepup:message', { detail: data }));
1833
+
1834
+ // Deprecated, kept for one release: the payment window of an application
1835
+ // still posts this and expects the shell to answer. An application listens
1836
+ // to `keepup:message` instead; this branch goes in the next minor release.
1837
+ if (data.type === 'payment_success') {
1838
+ showNotification('Payment successful! Plan activated.', 'success');
1839
+ if (typeof window.loadTariffsData === 'function') {
1840
+ setTimeout(() => window.loadTariffsData(), 1000);
1841
+ }
1842
+ }
1843
+ }
1844
+ window.addEventListener('message', keepupHandleWindowMessage);
1845
+
1846
+ /**
1847
+ * Event system for bulk markup application
1848
+ */
1849
+ const markupEventSystem = {
1850
+ listeners: new Map(),
1851
+ addListener(shopType, callback) {
1852
+ if (!this.listeners.has(shopType)) {
1853
+ this.listeners.set(shopType, []);
1854
+ }
1855
+ this.listeners.get(shopType).push(callback);
1856
+ },
1857
+ removeListener(shopType, callback) {
1858
+ if (this.listeners.has(shopType)) {
1859
+ const listeners = this.listeners.get(shopType);
1860
+ const index = listeners.indexOf(callback);
1861
+ if (index > -1) {
1862
+ listeners.splice(index, 1);
1863
+ }
1864
+ }
1865
+ },
1866
+ async dispatchEvent(shopType, eventData) {
1867
+ if (this.listeners.has(shopType)) {
1868
+ const listeners = this.listeners.get(shopType);
1869
+ const results = [];
1870
+
1871
+ for (const listener of listeners) {
1872
+ try {
1873
+ const result = await listener(eventData);
1874
+ results.push(result);
1875
+ } catch (error) {
1876
+ console.error(`Error in ${shopType} markup listener:`, error);
1877
+ results.push({ success: false, error: error.message });
1878
+ }
1879
+ }
1880
+
1881
+ return results;
1882
+ }
1883
+ return [];
1884
+ },
1885
+ getRegisteredShopTypes() {
1886
+ return Array.from(this.listeners.keys());
1887
+ }
1888
+ };
1889
+
1890
+ // Make it globally accessible for modules
1891
+ window.markupEventSystem = markupEventSystem;
1892
+
1893
+ // Periodic payment status check while the tariffs section is open
1894
+ function startPaymentStatusPolling() {
1895
+ setInterval(async () => {
1896
+ const tariffsSection = document.getElementById('tariffs-section');
1897
+ if (tariffsSection && tariffsSection.classList.contains('active')) {
1898
+ // Logic for checking pending payments could be added here
1899
+ }
1900
+ }, 30000);
1901
+ }
1902
+
1903
+ window.tableEvents = {
1904
+ listeners: [],
1905
+
1906
+ addListener(callback) {
1907
+ this.listeners.push(callback);
1908
+ },
1909
+
1910
+ notifyTableRendered(articles) {
1911
+ this.listeners.forEach(listener => {
1912
+ try {
1913
+ listener(articles);
1914
+ } catch (error) {
1915
+ console.error('Error in table event listener:', error);
1916
+ }
1917
+ });
1918
+ }
1919
+ };
1920
+
1921
+ // region application version handling
1922
+
1923
+ // Function for loading version information
1924
+ async function loadVersionInfo() {
1925
+ try {
1926
+ const token = localStorage.getItem('authToken');
1927
+ const response = await fetch('/api/version', {
1928
+ headers: {
1929
+ 'Authorization': `Bearer ${token}`
1930
+ }
1931
+ });
1932
+
1933
+ if (response.ok) {
1934
+ const versionData = await response.json();
1935
+ return versionData;
1936
+ }
1937
+ return null;
1938
+ } catch (error) {
1939
+ console.error('Failed to load version information:', error);
1940
+ return null;
1941
+ }
1942
+ }
1943
+
1944
+ // Function for displaying the version panel
1945
+ async function showVersionPanel() {
1946
+ const versionPanel = document.getElementById('versionPanel');
1947
+ if (!versionPanel) return;
1948
+
1949
+ // Check the user's roles
1950
+ if (currentUserIsAdmin()) {
1951
+ const versionData = await loadVersionInfo();
1952
+ if (versionData) {
1953
+ // Fill in the data
1954
+ document.getElementById('versionProject').textContent = versionData.project || '-';
1955
+ document.getElementById('versionRelease').textContent = versionData.version || '-';
1956
+ document.getElementById('versionBuildNumber').textContent = versionData.build_number || '-';
1957
+ document.getElementById('versionBuildDate').textContent = versionData.build_date || '-';
1958
+
1959
+ // Show the panel
1960
+ versionPanel.classList.remove('hidden');
1961
+
1962
+ // Refresh Feather icons
1963
+ feather.replace();
1964
+ }
1965
+ }
1966
+ }
1967
+
1968
+ // Function for hiding the version panel
1969
+ function hideVersionPanel() {
1970
+ const versionPanel = document.getElementById('versionPanel');
1971
+ if (versionPanel) {
1972
+ versionPanel.classList.add('hidden');
1973
+ }
1974
+ }
1975
+
1976
+ // Function for checking and updating the version panel's visibility
1977
+ function updateVersionPanelVisibility() {
1978
+ const versionPanel = document.getElementById('versionPanel');
1979
+ if (!versionPanel) return;
1980
+
1981
+ if (currentUserIsAdmin()) {
1982
+ showVersionPanel();
1983
+ } else {
1984
+ hideVersionPanel();
1985
+ }
1986
+ }
1987
+
1988
+ // endregion
1989
+
1990
+ // Function for opening the terms-of-use modal
1991
+ function openTermsModal() {
1992
+ const modal = document.getElementById('termsModal');
1993
+ applyPublicConfig();
1994
+ modal.classList.remove('hidden');
1995
+ modal.classList.add('active');
1996
+ document.body.style.overflow = 'hidden';
1997
+
1998
+ // Initialize feather icons
1999
+ if (typeof feather !== 'undefined') {
2000
+ feather.replace();
2001
+ }
2002
+ }
2003
+
2004
+ // Function for closing the terms-of-use modal
2005
+ function closeTermsModal() {
2006
+ const modal = document.getElementById('termsModal');
2007
+ modal.classList.remove('active');
2008
+
2009
+ // Small delay before hiding, for a smooth animation
2010
+ setTimeout(() => {
2011
+ modal.classList.add('hidden');
2012
+ document.body.style.overflow = '';
2013
+ }, 300);
2014
+ }
2015
+
2016
+ // The actions of the shell's own markup: the sidebar, the sign-in block, the
2017
+ // version panel and the terms modal carry data-action instead of an inline
2018
+ // handler, and the names below are the whole list the shell registers
2019
+ // (keepup-93). A section registers its own under its own prefix.
2020
+ KeepupActions.register({
2021
+ 'shell.logout': () => logout(),
2022
+ 'shell.toggle-password': (element) => togglePasswordVisibility(element.dataset.input),
2023
+ 'shell.open-terms': () => openTermsModal(),
2024
+ 'shell.close-terms': () => closeTermsModal(),
2025
+ 'shell.hide-version-panel': () => hideVersionPanel(),
2026
+ 'shell.dismiss-notification': (element) => {
2027
+ const notification = element.closest('[data-notification]');
2028
+ if (notification) notification.remove();
2029
+ },
2030
+ });
2031
+
2032
+ // Close on click outside the content
2033
+ document.addEventListener('DOMContentLoaded', function() {
2034
+ const modal = document.getElementById('termsModal');
2035
+ if (modal) {
2036
+ modal.addEventListener('click', function(e) {
2037
+ if (e.target === this) {
2038
+ closeTermsModal();
2039
+ }
2040
+ });
2041
+ }
2042
+
2043
+ // Close on Escape
2044
+ document.addEventListener('keydown', function(e) {
2045
+ if (e.key === 'Escape') {
2046
+ const modal = document.getElementById('termsModal');
2047
+ if (modal && !modal.classList.contains('hidden')) {
2048
+ closeTermsModal();
2049
+ }
2050
+ }
2051
+ });
2052
+ });
2053
+
2054
+ /**
2055
+ * The "forgot password" link leads to the bot: the reset is issued by it,
2056
+ * not this page (task 272). The bot's name is known to the server; without
2057
+ * a response the link stays plain text.
2058
+ */
2059
+ function applyForgotPasswordLink() {
2060
+ const link = document.getElementById('forgotPasswordLink');
2061
+ if (!link) return;
2062
+ loadPublicConfig().then(config => {
2063
+ // The whole address comes from the server: building it here would mean
2064
+ // hardcoding someone else's domain into a panel that loads nothing of
2065
+ // its own. Where it comes from is the application's business: for one
2066
+ // it's a Telegram bot, for another its own page, for a third there's
2067
+ // no reset at all, and then the link stays plain text. Before task
2068
+ // keepup-27 the shell asked for the address directly at the bot's
2069
+ // route, meaning the framework knew about the bot (keepup-27).
2070
+ const url = config && config.password_reset_url;
2071
+ if (!url) return;
2072
+ link.href = url;
2073
+ link.target = '_blank';
2074
+ link.rel = 'noopener noreferrer';
2075
+ }).catch(() => { /* without an address the sign-in page works as before */ });
2076
+ }