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.
- keepup_http/__init__.py +38 -0
- keepup_http/api_docs.py +76 -0
- keepup_http/body_limit.py +156 -0
- keepup_http/events_api.py +237 -0
- keepup_http/factory.py +562 -0
- keepup_http/lock_routes.py +155 -0
- keepup_http/plugin.py +53 -0
- keepup_http/routes.py +549 -0
- keepup_http/static/css/aos.css +1 -0
- keepup_http/static/css/main_nebula.css +232 -0
- keepup_http/static/css/main_new.css +869 -0
- keepup_http/static/css/tailwind.css +1 -0
- keepup_http/static/index_nebula.html +282 -0
- keepup_http/static/index_new.html +275 -0
- keepup_http/static/js/aos.js +1 -0
- keepup_http/static/js/feather-icons.js +13 -0
- keepup_http/static/js/layout_bootstrap.js +29 -0
- keepup_http/static/js/main_new.js +2076 -0
- keepup_http/static/js/tailwind.js +83 -0
- keepup_http/static/modules/css/background_tasks.css +233 -0
- keepup_http/static/modules/css/cluster.css +16 -0
- keepup_http/static/modules/css/event_manager.css +386 -0
- keepup_http/static/modules/css/integration_logs.css +33 -0
- keepup_http/static/modules/css/metrics.css +115 -0
- keepup_http/static/modules/css/modules.css +189 -0
- keepup_http/static/modules/css/themes.css +563 -0
- keepup_http/static/modules/css/users.css +278 -0
- keepup_http/static/modules/js/background_tasks.js +695 -0
- keepup_http/static/modules/js/chart.js +14 -0
- keepup_http/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
- keepup_http/static/modules/js/cluster.js +374 -0
- keepup_http/static/modules/js/event_manager.js +1007 -0
- keepup_http/static/modules/js/integration_logs.js +795 -0
- keepup_http/static/modules/js/metrics.js +929 -0
- keepup_http/static/modules/js/modules.js +1128 -0
- keepup_http/static/modules/js/themes.js +684 -0
- keepup_http/static/modules/js/users.js +898 -0
- keepup_http/web.py +214 -0
- keepup_http-0.5.0.dist-info/METADATA +29 -0
- keepup_http-0.5.0.dist-info/RECORD +43 -0
- keepup_http-0.5.0.dist-info/WHEEL +5 -0
- keepup_http-0.5.0.dist-info/entry_points.txt +2 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
1749
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
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
|
+
}
|