keepup-http 0.6.0__tar.gz → 0.7.0__tar.gz
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-0.6.0 → keepup_http-0.7.0}/PKG-INFO +1 -1
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/__init__.py +1 -1
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/factory.py +5 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/css/main_nebula.css +22 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/css/main_new.css +173 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/index_nebula.html +29 -14
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/index_new.html +29 -14
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/js/main_new.js +479 -61
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http.egg-info/PKG-INFO +1 -1
- {keepup_http-0.6.0 → keepup_http-0.7.0}/pyproject.toml +1 -1
- {keepup_http-0.6.0 → keepup_http-0.7.0}/README.md +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/api_docs.py +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/body_limit.py +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/events_api.py +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/lock_routes.py +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/plugin.py +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/routes.py +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/css/aos.css +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/css/tailwind.css +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/js/aos.js +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/js/feather-icons.js +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/js/layout_bootstrap.js +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/js/mfa_panel.js +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/js/tailwind.js +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/background_tasks.css +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/cluster.css +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/event_manager.css +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/integration_logs.css +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/metrics.css +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/modules.css +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/themes.css +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/users.css +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/background_tasks.js +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/chart.js +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/cluster.js +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/event_manager.js +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/integration_logs.js +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/metrics.js +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/modules.js +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/themes.js +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/users.js +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/web.py +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http.egg-info/SOURCES.txt +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http.egg-info/dependency_links.txt +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http.egg-info/entry_points.txt +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http.egg-info/requires.txt +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http.egg-info/top_level.txt +0 -0
- {keepup_http-0.6.0 → keepup_http-0.7.0}/setup.cfg +0 -0
|
@@ -10,7 +10,7 @@ route wrapper; they move in one slice at a time, and the base answers their old
|
|
|
10
10
|
names through the compatibility layer for one release (keepup-122).
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
-
__version__ = "0.
|
|
13
|
+
__version__ = "0.7.0"
|
|
14
14
|
|
|
15
15
|
__all__ = ["HttpPlugin", "SERVICE_HTTP", "register_api_documentation",
|
|
16
16
|
"register_event_api_routes"]
|
|
@@ -169,6 +169,11 @@ def apply_settings(settings: KeepupSettings) -> None:
|
|
|
169
169
|
favicon_file=settings.favicon_file,
|
|
170
170
|
)
|
|
171
171
|
auth_dependencies.configure_panel_gate(pending=settings.pending_documents)
|
|
172
|
+
# The panel's cookies carry the application's name, so several keepup
|
|
173
|
+
# applications on one host do not overwrite each other's session (0.7.0).
|
|
174
|
+
panel_session.configure(
|
|
175
|
+
settings.cookie_prefix if settings.cookie_prefix is not None
|
|
176
|
+
else settings.project_name)
|
|
172
177
|
if not settings.database_dialect:
|
|
173
178
|
# The framework's configuration is the default; naming the dialect lets
|
|
174
179
|
# the kernel choose a driver without importing a database (keepup-123).
|
|
@@ -47,6 +47,28 @@
|
|
|
47
47
|
--nav-item-bg-active: rgba(6, 182, 212, 0.24);
|
|
48
48
|
--nav-item-icon-gap: 0.75rem;
|
|
49
49
|
|
|
50
|
+
/* The menu's search (keepup-179). A translucent field on a translucent
|
|
51
|
+
sidebar reads as a hole in it, so this theme gives the field a border of
|
|
52
|
+
its own and the focus ring the accent colour of the buttons. */
|
|
53
|
+
--nav-search-bg: rgba(255, 255, 255, 0.05);
|
|
54
|
+
--nav-search-bg-focus: rgba(6, 182, 212, 0.12);
|
|
55
|
+
--nav-search-border: rgba(148, 163, 184, 0.22);
|
|
56
|
+
--nav-search-border-focus: rgba(6, 182, 212, 0.6);
|
|
57
|
+
--nav-search-radius: 10px;
|
|
58
|
+
--nav-search-fg: var(--nebula-text-dim);
|
|
59
|
+
--nav-search-value: #ffffff;
|
|
60
|
+
--nav-search-placeholder: var(--nebula-text-dim);
|
|
61
|
+
--nav-item-search-base: 1.25rem;
|
|
62
|
+
--nav-item-search-step: 1rem;
|
|
63
|
+
|
|
64
|
+
/* The match and the keyboard's row (keepup-181). The mark borrows the
|
|
65
|
+
theme's cyan rather than the default amber: on this sidebar the amber
|
|
66
|
+
reads as a warning, and the cyan is what a control here looks like. */
|
|
67
|
+
--nav-search-hit-bg: rgba(6, 182, 212, 0.9);
|
|
68
|
+
--nav-search-hit-fg: #04121c;
|
|
69
|
+
--nav-search-current-bg: rgba(6, 182, 212, 0.2);
|
|
70
|
+
--nav-search-current-ring: rgba(6, 182, 212, 0.7);
|
|
71
|
+
|
|
50
72
|
--modules-nav-max-height: calc(100vh - 200px);
|
|
51
73
|
--modules-nav-padding: 8px 10px 16px;
|
|
52
74
|
--sidebar-scrollbar-track: rgba(255, 255, 255, 0.04);
|
|
@@ -35,6 +35,29 @@
|
|
|
35
35
|
--nav-item-bg-active: #1e40af;
|
|
36
36
|
--nav-item-icon-gap: 0.75rem;
|
|
37
37
|
|
|
38
|
+
/* The search of the menu (keepup-179). A field sits above the entries and
|
|
39
|
+
a match that is a submodule is pushed right by its depth, so a result
|
|
40
|
+
shown without its parent still reads as a sub-item. */
|
|
41
|
+
--nav-search-bg: rgba(255, 255, 255, 0.12);
|
|
42
|
+
--nav-search-bg-focus: rgba(255, 255, 255, 0.2);
|
|
43
|
+
--nav-search-border: rgba(255, 255, 255, 0.16);
|
|
44
|
+
--nav-search-border-focus: rgba(255, 255, 255, 0.5);
|
|
45
|
+
--nav-search-radius: 10px;
|
|
46
|
+
--nav-search-fg: rgba(255, 255, 255, 0.75);
|
|
47
|
+
--nav-search-value: #ffffff;
|
|
48
|
+
--nav-search-placeholder: rgba(255, 255, 255, 0.6);
|
|
49
|
+
--nav-item-search-base: 1.5rem;
|
|
50
|
+
--nav-item-search-step: 1.1rem;
|
|
51
|
+
|
|
52
|
+
/* The match inside a result, and the row the arrow keys are on
|
|
53
|
+
(keepup-181). The mark is a colour pair of its own so it reads on the
|
|
54
|
+
sidebar; the current row is a ring rather than a fill, because hover has
|
|
55
|
+
already claimed the fill. */
|
|
56
|
+
--nav-search-hit-bg: #fde68a;
|
|
57
|
+
--nav-search-hit-fg: #1e3a8a;
|
|
58
|
+
--nav-search-current-bg: rgba(255, 255, 255, 0.2);
|
|
59
|
+
--nav-search-current-ring: rgba(255, 255, 255, 0.55);
|
|
60
|
+
|
|
38
61
|
/* Adaptive layout. --layout-breakpoint is the single source of the
|
|
39
62
|
narrow/wide boundary: main_new.js reads it off :root, drives a
|
|
40
63
|
matchMedia off it and keeps the .is-narrow class on <html>, and the
|
|
@@ -224,6 +247,148 @@
|
|
|
224
247
|
transition: margin 0.3s ease;
|
|
225
248
|
}
|
|
226
249
|
|
|
250
|
+
/* The field above the menu (keepup-179). Chrome, so it is declared here and a
|
|
251
|
+
section must not restate it. It sits outside #modules-nav-container on
|
|
252
|
+
purpose: the entries scroll under it, the field stays where it was put. */
|
|
253
|
+
.nav-search {
|
|
254
|
+
display: flex;
|
|
255
|
+
align-items: center;
|
|
256
|
+
margin: 0.25rem 0.75rem 0.5rem;
|
|
257
|
+
padding: 0 0.75rem;
|
|
258
|
+
border: 1px solid var(--nav-search-border, rgba(255, 255, 255, 0.16));
|
|
259
|
+
border-radius: var(--nav-search-radius, 10px);
|
|
260
|
+
background-color: var(--nav-search-bg, rgba(255, 255, 255, 0.12));
|
|
261
|
+
transition: var(--chrome-transition, all 0.3s ease);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.nav-search:focus-within {
|
|
265
|
+
border-color: var(--nav-search-border-focus, rgba(255, 255, 255, 0.5));
|
|
266
|
+
background-color: var(--nav-search-bg-focus, rgba(255, 255, 255, 0.2));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.nav-search-icon {
|
|
270
|
+
flex: 0 0 auto;
|
|
271
|
+
width: 1rem;
|
|
272
|
+
height: 1rem;
|
|
273
|
+
color: var(--nav-search-fg, rgba(255, 255, 255, 0.75));
|
|
274
|
+
stroke-width: 2;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.nav-search-input {
|
|
278
|
+
flex: 1 1 auto;
|
|
279
|
+
min-width: 0;
|
|
280
|
+
width: 100%;
|
|
281
|
+
padding: 0.55rem 0.5rem;
|
|
282
|
+
border: 0;
|
|
283
|
+
background: transparent;
|
|
284
|
+
color: var(--nav-search-value, #ffffff);
|
|
285
|
+
font: inherit;
|
|
286
|
+
font-size: 0.875rem;
|
|
287
|
+
outline: none;
|
|
288
|
+
-webkit-appearance: none;
|
|
289
|
+
appearance: none;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.nav-search-input::placeholder {
|
|
293
|
+
color: var(--nav-search-placeholder, rgba(255, 255, 255, 0.6));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* The field carries its own clear button: the browser's would be a second one
|
|
297
|
+
with a different look, and on some engines it cannot be styled at all. */
|
|
298
|
+
.nav-search-input::-webkit-search-cancel-button,
|
|
299
|
+
.nav-search-input::-webkit-search-decoration {
|
|
300
|
+
-webkit-appearance: none;
|
|
301
|
+
appearance: none;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.nav-search-clear {
|
|
305
|
+
flex: 0 0 auto;
|
|
306
|
+
display: inline-flex;
|
|
307
|
+
align-items: center;
|
|
308
|
+
justify-content: center;
|
|
309
|
+
width: 1.5rem;
|
|
310
|
+
height: 1.5rem;
|
|
311
|
+
padding: 0;
|
|
312
|
+
border: 0;
|
|
313
|
+
border-radius: 9999px;
|
|
314
|
+
background: transparent;
|
|
315
|
+
color: var(--nav-search-fg, rgba(255, 255, 255, 0.75));
|
|
316
|
+
cursor: pointer;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.nav-search-clear:hover,
|
|
320
|
+
.nav-search-clear:focus-visible {
|
|
321
|
+
color: var(--nav-search-value, #ffffff);
|
|
322
|
+
background-color: var(--nav-search-bg-focus, rgba(255, 255, 255, 0.2));
|
|
323
|
+
outline: none;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.nav-search-clear[hidden] {
|
|
327
|
+
display: none;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.nav-search-clear-icon {
|
|
331
|
+
width: 0.85rem;
|
|
332
|
+
height: 0.85rem;
|
|
333
|
+
stroke-width: 2.5;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.nav-search-empty {
|
|
337
|
+
margin: 0.25rem 1.5rem 0.5rem;
|
|
338
|
+
color: var(--nav-search-placeholder, rgba(255, 255, 255, 0.6));
|
|
339
|
+
font-size: 0.8rem;
|
|
340
|
+
line-height: 1.35;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.nav-search-empty[hidden] {
|
|
344
|
+
display: none;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/* What the search leaves on screen: an entry that does not match goes, an entry
|
|
348
|
+
the match lives inside stays so the result keeps its path. */
|
|
349
|
+
.nav-item.nav-search-hidden {
|
|
350
|
+
display: none !important;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/* A matched submodule is indented by its depth: shown without its parent it
|
|
354
|
+
would otherwise look like a section of its own. The dot is the second cue, so
|
|
355
|
+
the depth reads even when the two items sit far from the menu's edge. */
|
|
356
|
+
.nav-item.nav-search-match {
|
|
357
|
+
padding-left: calc(
|
|
358
|
+
var(--nav-item-search-base, 1.5rem)
|
|
359
|
+
+ var(--nav-search-depth, 0) * var(--nav-item-search-step, 1.1rem)
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.nav-item.nav-search-nested::before {
|
|
364
|
+
content: "";
|
|
365
|
+
flex: 0 0 auto;
|
|
366
|
+
width: 4px;
|
|
367
|
+
height: 4px;
|
|
368
|
+
margin-right: 0.5rem;
|
|
369
|
+
border-radius: 9999px;
|
|
370
|
+
background-color: currentColor;
|
|
371
|
+
opacity: 0.55;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* The substring that matched is marked in place, so the eye lands on it before
|
|
375
|
+
it reads the rest of the label. The mark's colours are the theme's: on a
|
|
376
|
+
saturated sidebar the text alone is not enough of a cue. */
|
|
377
|
+
.nav-search-hit {
|
|
378
|
+
padding: 0 2px;
|
|
379
|
+
border-radius: 3px;
|
|
380
|
+
background-color: var(--nav-search-hit-bg, #fde68a);
|
|
381
|
+
color: var(--nav-search-hit-fg, #1e3a8a);
|
|
382
|
+
font-weight: 600;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/* Where the arrow keys are. It is not hover -- the pointer may be elsewhere --
|
|
386
|
+
so it gets a ring that survives the row's own hover and active colours. */
|
|
387
|
+
.nav-item.nav-search-current {
|
|
388
|
+
background-color: var(--nav-search-current-bg, rgba(255, 255, 255, 0.2));
|
|
389
|
+
box-shadow: inset 0 0 0 1px var(--nav-search-current-ring, rgba(255, 255, 255, 0.55));
|
|
390
|
+
}
|
|
391
|
+
|
|
227
392
|
/* Collapsed sidebar. Declared once, for the whole panel, and only for the wide
|
|
228
393
|
layout: below the breakpoint the panel is a drawer, so a collapsed flag saved
|
|
229
394
|
on a desktop would narrow the drawer and shift the content 64px to the right
|
|
@@ -238,6 +403,14 @@
|
|
|
238
403
|
display: none;
|
|
239
404
|
}
|
|
240
405
|
|
|
406
|
+
/* The field belongs to the wide menu: collapsed, there is no room for it and
|
|
407
|
+
nothing to type into. The script clears the filter on the way down, so no
|
|
408
|
+
section is left hidden behind a field nobody can see. */
|
|
409
|
+
:root:not(.is-narrow) .sidebar-collapsed .nav-search,
|
|
410
|
+
:root:not(.is-narrow) .sidebar-collapsed .nav-search-empty {
|
|
411
|
+
display: none;
|
|
412
|
+
}
|
|
413
|
+
|
|
241
414
|
:root:not(.is-narrow) .sidebar-collapsed .logo-text {
|
|
242
415
|
display: none;
|
|
243
416
|
}
|
|
@@ -65,18 +65,27 @@
|
|
|
65
65
|
<input type="text" id="loginUsername" required
|
|
66
66
|
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
67
67
|
</div>
|
|
68
|
-
<div class="mb-
|
|
68
|
+
<div class="mb-4">
|
|
69
69
|
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
|
70
70
|
<input type="password" id="loginPassword" required
|
|
71
71
|
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
72
72
|
</div>
|
|
73
|
+
<div class="mb-6 flex items-center">
|
|
74
|
+
<input type="checkbox" id="loginRemember"
|
|
75
|
+
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
76
|
+
<label for="loginRemember" class="ml-2 block text-sm text-gray-700">
|
|
77
|
+
Stay signed in
|
|
78
|
+
</label>
|
|
79
|
+
</div>
|
|
73
80
|
<button type="submit" class="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 font-medium">
|
|
74
81
|
Sign in
|
|
75
82
|
</button>
|
|
76
|
-
|
|
83
|
+
<!-- Shown only when the deployment names a page of its own
|
|
84
|
+
for a reset: where a password is reset is the
|
|
85
|
+
application's business, not the shell's (keepup-178). -->
|
|
86
|
+
<p id="forgotPasswordHint" class="mt-3 text-center text-sm text-gray-500 hidden">
|
|
77
87
|
Forgot your password?
|
|
78
|
-
<a id="forgotPasswordLink" href="#" class="text-blue-600 hover:underline">
|
|
79
|
-
and send <code>/password</code>.
|
|
88
|
+
<a id="forgotPasswordLink" href="#" class="text-blue-600 hover:underline">Reset it</a>.
|
|
80
89
|
</p>
|
|
81
90
|
</form>
|
|
82
91
|
</div>
|
|
@@ -169,6 +178,22 @@
|
|
|
169
178
|
</div>
|
|
170
179
|
<nav class="mt-4" id="main-nav">
|
|
171
180
|
|
|
181
|
+
<!-- The search of the menu (keepup-179). It is chrome: the
|
|
182
|
+
theme styles it, and the shell's script narrows the entries
|
|
183
|
+
below to what is typed. No inline handler -- the panel is
|
|
184
|
+
served with a Content-Security-Policy that refuses one. -->
|
|
185
|
+
<div id="nav-search" class="nav-search" role="search">
|
|
186
|
+
<i data-feather="search" class="nav-search-icon" aria-hidden="true"></i>
|
|
187
|
+
<input id="nav-search-input" class="nav-search-input" type="search"
|
|
188
|
+
placeholder="Search modules" aria-label="Search modules"
|
|
189
|
+
aria-controls="modules-nav-container" autocomplete="off" spellcheck="false">
|
|
190
|
+
<button id="nav-search-clear" class="nav-search-clear" type="button"
|
|
191
|
+
aria-label="Clear the search" hidden>
|
|
192
|
+
<i data-feather="x" class="nav-search-clear-icon" aria-hidden="true"></i>
|
|
193
|
+
</button>
|
|
194
|
+
</div>
|
|
195
|
+
<p id="nav-search-empty" class="nav-search-empty" role="status" hidden></p>
|
|
196
|
+
|
|
172
197
|
<div id="modules-nav-container"></div>
|
|
173
198
|
</nav>
|
|
174
199
|
</div>
|
|
@@ -228,16 +253,6 @@
|
|
|
228
253
|
<!-- Content Sections -->
|
|
229
254
|
<main class="p-6">
|
|
230
255
|
<div id="modules-container"></div>
|
|
231
|
-
<div id="profile-section" class="content-section">
|
|
232
|
-
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
233
|
-
<div id="profile-content">
|
|
234
|
-
<div class="text-center py-8">
|
|
235
|
-
<i data-feather="user" class="w-12 h-12 text-gray-400 mx-auto mb-4"></i>
|
|
236
|
-
<p class="text-gray-500">Loading profile...</p>
|
|
237
|
-
</div>
|
|
238
|
-
</div>
|
|
239
|
-
</div>
|
|
240
|
-
</div>
|
|
241
256
|
</main>
|
|
242
257
|
</div>
|
|
243
258
|
</div>
|
|
@@ -64,18 +64,27 @@
|
|
|
64
64
|
<input type="text" id="loginUsername" required
|
|
65
65
|
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
66
66
|
</div>
|
|
67
|
-
<div class="mb-
|
|
67
|
+
<div class="mb-4">
|
|
68
68
|
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
|
69
69
|
<input type="password" id="loginPassword" required
|
|
70
70
|
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
71
71
|
</div>
|
|
72
|
+
<div class="mb-6 flex items-center">
|
|
73
|
+
<input type="checkbox" id="loginRemember"
|
|
74
|
+
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
75
|
+
<label for="loginRemember" class="ml-2 block text-sm text-gray-700">
|
|
76
|
+
Stay signed in
|
|
77
|
+
</label>
|
|
78
|
+
</div>
|
|
72
79
|
<button type="submit" class="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 font-medium">
|
|
73
80
|
Sign in
|
|
74
81
|
</button>
|
|
75
|
-
|
|
82
|
+
<!-- Shown only when the deployment names a page of its own
|
|
83
|
+
for a reset: where a password is reset is the
|
|
84
|
+
application's business, not the shell's (keepup-178). -->
|
|
85
|
+
<p id="forgotPasswordHint" class="mt-3 text-center text-sm text-gray-500 hidden">
|
|
76
86
|
Forgot your password?
|
|
77
|
-
<a id="forgotPasswordLink" href="#" class="text-blue-600 hover:underline">
|
|
78
|
-
and send <code>/password</code>.
|
|
87
|
+
<a id="forgotPasswordLink" href="#" class="text-blue-600 hover:underline">Reset it</a>.
|
|
79
88
|
</p>
|
|
80
89
|
</form>
|
|
81
90
|
</div>
|
|
@@ -162,6 +171,22 @@
|
|
|
162
171
|
</div>
|
|
163
172
|
<nav class="mt-4" id="main-nav">
|
|
164
173
|
|
|
174
|
+
<!-- The search of the menu (keepup-179). It is chrome: the
|
|
175
|
+
theme styles it, and the shell's script narrows the entries
|
|
176
|
+
below to what is typed. No inline handler -- the panel is
|
|
177
|
+
served with a Content-Security-Policy that refuses one. -->
|
|
178
|
+
<div id="nav-search" class="nav-search" role="search">
|
|
179
|
+
<i data-feather="search" class="nav-search-icon" aria-hidden="true"></i>
|
|
180
|
+
<input id="nav-search-input" class="nav-search-input" type="search"
|
|
181
|
+
placeholder="Search modules" aria-label="Search modules"
|
|
182
|
+
aria-controls="modules-nav-container" autocomplete="off" spellcheck="false">
|
|
183
|
+
<button id="nav-search-clear" class="nav-search-clear" type="button"
|
|
184
|
+
aria-label="Clear the search" hidden>
|
|
185
|
+
<i data-feather="x" class="nav-search-clear-icon" aria-hidden="true"></i>
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
<p id="nav-search-empty" class="nav-search-empty" role="status" hidden></p>
|
|
189
|
+
|
|
165
190
|
<div id="modules-nav-container"></div>
|
|
166
191
|
</nav>
|
|
167
192
|
</div>
|
|
@@ -221,16 +246,6 @@
|
|
|
221
246
|
<!-- Content Sections -->
|
|
222
247
|
<main class="p-6">
|
|
223
248
|
<div id="modules-container"></div>
|
|
224
|
-
<div id="profile-section" class="content-section">
|
|
225
|
-
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
226
|
-
<div id="profile-content">
|
|
227
|
-
<div class="text-center py-8">
|
|
228
|
-
<i data-feather="user" class="w-12 h-12 text-gray-400 mx-auto mb-4"></i>
|
|
229
|
-
<p class="text-gray-500">Loading profile...</p>
|
|
230
|
-
</div>
|
|
231
|
-
</div>
|
|
232
|
-
</div>
|
|
233
|
-
</div>
|
|
234
249
|
</main>
|
|
235
250
|
</div>
|
|
236
251
|
</div>
|
|
@@ -13,14 +13,21 @@ let currentUser = null;
|
|
|
13
13
|
which the wrapper below adds to every same-origin request that is not a read.
|
|
14
14
|
========================================================================== */
|
|
15
15
|
const SESSION_MARKER = 'cookie';
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
16
|
+
// A cookie belongs to a host, not to a port: several keepup panels on one host
|
|
17
|
+
// used to write the same `ss_csrf`, and whichever signed in last left the
|
|
18
|
+
// others unable to prove a change came from their own page. Since 0.7.0 the
|
|
19
|
+
// CSRF cookie carries the application's own name, and the server publishes
|
|
20
|
+
// both names in /api/public/config -- they are not credentials. Over HTTPS the
|
|
21
|
+
// name also carries the `__Host-` prefix (keepup-92), which belongs to this
|
|
22
|
+
// exact host, so a sibling subdomain cannot plant it; the plain names of
|
|
23
|
+
// 0.6.0 are kept as a fallback for a page whose config did not arrive.
|
|
24
|
+
const LEGACY_CSRF_COOKIES = ['__Host-ss_csrf', 'ss_csrf'];
|
|
21
25
|
const CSRF_HEADER = 'X-CSRF-Token';
|
|
22
26
|
const CSRF_SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
|
|
23
27
|
|
|
28
|
+
let publicConfigPromise = null;
|
|
29
|
+
let csrfCookieNamesPromise = null;
|
|
30
|
+
|
|
24
31
|
function readCookie(name) {
|
|
25
32
|
const prefix = name + '=';
|
|
26
33
|
const found = (document.cookie || '').split(';')
|
|
@@ -29,8 +36,21 @@ function readCookie(name) {
|
|
|
29
36
|
return found ? decodeURIComponent(found.slice(prefix.length)) : null;
|
|
30
37
|
}
|
|
31
38
|
|
|
32
|
-
function
|
|
33
|
-
|
|
39
|
+
function csrfCookieNames() {
|
|
40
|
+
if (!csrfCookieNamesPromise) {
|
|
41
|
+
csrfCookieNamesPromise = Promise.resolve()
|
|
42
|
+
.then(() => (typeof loadPublicConfig === 'function' ? loadPublicConfig() : null))
|
|
43
|
+
.then(config => {
|
|
44
|
+
const base = config && config.panel_cookies && config.panel_cookies.csrf;
|
|
45
|
+
return base ? ['__Host-' + base, base] : LEGACY_CSRF_COOKIES;
|
|
46
|
+
})
|
|
47
|
+
.catch(() => LEGACY_CSRF_COOKIES);
|
|
48
|
+
}
|
|
49
|
+
return csrfCookieNamesPromise;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readCsrfCookie() {
|
|
53
|
+
for (const name of await csrfCookieNames()) {
|
|
34
54
|
const value = readCookie(name);
|
|
35
55
|
if (value) return value;
|
|
36
56
|
}
|
|
@@ -50,17 +70,19 @@ function installCsrfFetch(target) {
|
|
|
50
70
|
if (!originalFetch || originalFetch.csrfAware) {
|
|
51
71
|
return;
|
|
52
72
|
}
|
|
53
|
-
const wrapped = function (input, init) {
|
|
73
|
+
const wrapped = async function (input, init) {
|
|
54
74
|
const request = typeof Request !== 'undefined' && input instanceof Request ? input : null;
|
|
55
75
|
const method = String((init && init.method) || (request && request.method) || 'GET').toUpperCase();
|
|
56
76
|
const url = request ? request.url : String(input);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
headers.
|
|
77
|
+
if (!CSRF_SAFE_METHODS.includes(method) && isSameOriginUrl(url)) {
|
|
78
|
+
const csrf = await readCsrfCookie();
|
|
79
|
+
if (csrf) {
|
|
80
|
+
const headers = new Headers((init && init.headers) || (request && request.headers) || undefined);
|
|
81
|
+
if (!headers.has(CSRF_HEADER)) {
|
|
82
|
+
headers.set(CSRF_HEADER, csrf);
|
|
83
|
+
}
|
|
84
|
+
init = Object.assign({}, init || {}, { headers });
|
|
62
85
|
}
|
|
63
|
-
init = Object.assign({}, init || {}, { headers });
|
|
64
86
|
}
|
|
65
87
|
return originalFetch.call(target, input, init);
|
|
66
88
|
};
|
|
@@ -405,6 +427,10 @@ function toggleSidebar() {
|
|
|
405
427
|
sidebar.classList.toggle('sidebar-collapsed');
|
|
406
428
|
const isCollapsed = sidebar.classList.contains('sidebar-collapsed');
|
|
407
429
|
localStorage.setItem('sidebarCollapsed', isCollapsed);
|
|
430
|
+
|
|
431
|
+
// The collapsed menu shows icons only and the search field goes with the
|
|
432
|
+
// text, so a filter left on would look like sections that went missing.
|
|
433
|
+
if (isCollapsed) clearModuleSearch();
|
|
408
434
|
}
|
|
409
435
|
|
|
410
436
|
// Restore menu state on page load
|
|
@@ -661,7 +687,9 @@ function initializeApp() {
|
|
|
661
687
|
setupRouter();
|
|
662
688
|
registerAppSections();
|
|
663
689
|
setupNavigation();
|
|
690
|
+
setupModuleSearch();
|
|
664
691
|
setupAuthForms();
|
|
692
|
+
installSessionKeepAlive();
|
|
665
693
|
|
|
666
694
|
// A token left from before the cookie is traded for one and forgotten; with
|
|
667
695
|
// no token at all the cookie may still hold a session, so it is asked too.
|
|
@@ -867,7 +895,15 @@ async function adoptSession() {
|
|
|
867
895
|
}
|
|
868
896
|
localStorage.removeItem('authToken');
|
|
869
897
|
}
|
|
870
|
-
|
|
898
|
+
let valid = await verifyToken();
|
|
899
|
+
if (!valid) {
|
|
900
|
+
// The token in the cookie is gone -- the browser was closed, or the
|
|
901
|
+
// machine slept past it. A session asked to stay signed in still has
|
|
902
|
+
// its renewal credential and renews here without asking for a
|
|
903
|
+
// password; one that was not keeps the refusal (keepup-177).
|
|
904
|
+
valid = await refreshSession(false);
|
|
905
|
+
}
|
|
906
|
+
return valid;
|
|
871
907
|
}
|
|
872
908
|
|
|
873
909
|
function showSection(sectionId) {
|
|
@@ -1073,8 +1109,8 @@ function showAuthModal() {
|
|
|
1073
1109
|
|
|
1074
1110
|
// What this deployment offers before anybody has signed in: whether accounts
|
|
1075
1111
|
// can be created here at all, and the terms a person is asked to agree to.
|
|
1076
|
-
|
|
1077
|
-
|
|
1112
|
+
// (The promise itself is declared with the CSRF wrapper above, because the
|
|
1113
|
+
// cookie names a change must send arrive in the same answer.)
|
|
1078
1114
|
function loadPublicConfig() {
|
|
1079
1115
|
if (!publicConfigPromise) {
|
|
1080
1116
|
publicConfigPromise = fetch('/api/public/config')
|
|
@@ -1090,7 +1126,7 @@ async function applyPublicConfig() {
|
|
|
1090
1126
|
|
|
1091
1127
|
// Registration closed is the default, so the tab stays out of sight unless
|
|
1092
1128
|
// the server says otherwise: a form that creates accounts nobody activates
|
|
1093
|
-
// contradicts the
|
|
1129
|
+
// contradicts the sign-in screen, which says registration is by invitation.
|
|
1094
1130
|
if (registerTab) {
|
|
1095
1131
|
registerTab.hidden = !(config && config.self_registration);
|
|
1096
1132
|
}
|
|
@@ -1124,6 +1160,317 @@ function setupNavigation() {
|
|
|
1124
1160
|
});
|
|
1125
1161
|
}
|
|
1126
1162
|
|
|
1163
|
+
/* ==========================================================================
|
|
1164
|
+
The search of the sidebar (keepup-179).
|
|
1165
|
+
|
|
1166
|
+
The menu of a full panel is longer than a screen, and the person looking for
|
|
1167
|
+
one section reads it top to bottom. The field above the menu narrows the menu
|
|
1168
|
+
to the entries whose text contains what was typed -- a substring, in any
|
|
1169
|
+
case, front-end only: nothing is asked of the server and nothing is stored.
|
|
1170
|
+
|
|
1171
|
+
A submenu is drawn from the same elements, so the search has to keep the
|
|
1172
|
+
shape as well as the text. An entry is a submodule when it sits inside
|
|
1173
|
+
another entry (a `.nav-item` inside a `.nav-item`) or inside a container the
|
|
1174
|
+
parent keeps for its children (`data-nav-children`); its depth is the deeper
|
|
1175
|
+
of the two counts. A match that comes without its parent is indented by that
|
|
1176
|
+
depth and carries a leading dot, so a result never reads as a top-level
|
|
1177
|
+
section.
|
|
1178
|
+
|
|
1179
|
+
The parent of a match stays on screen even when its own text does not match:
|
|
1180
|
+
hiding it would hide the child that lives inside it. A parent whose children
|
|
1181
|
+
sit in a sibling container can be hidden, and then the child stands alone --
|
|
1182
|
+
which is the case the indentation exists for.
|
|
1183
|
+
|
|
1184
|
+
A result also says where the match is: the substring that matched is marked
|
|
1185
|
+
inside the label, in the label's own case, and ArrowDown/ArrowUp walk the
|
|
1186
|
+
matches on screen so Enter can open the one that was aimed at rather than
|
|
1187
|
+
whichever came first.
|
|
1188
|
+
========================================================================== */
|
|
1189
|
+
// --- the search field of the sidebar
|
|
1190
|
+
const NAV_SEARCH_ROOT_ID = 'modules-nav-container';
|
|
1191
|
+
const NAV_SEARCH_INPUT_ID = 'nav-search-input';
|
|
1192
|
+
const NAV_SEARCH_CLEAR_ID = 'nav-search-clear';
|
|
1193
|
+
const NAV_SEARCH_EMPTY_ID = 'nav-search-empty';
|
|
1194
|
+
const NAV_SEARCH_ITEM = '.nav-item';
|
|
1195
|
+
const NAV_SEARCH_CHILDREN = '[data-nav-children]';
|
|
1196
|
+
const NAV_SEARCH_HIDDEN = 'nav-search-hidden';
|
|
1197
|
+
const NAV_SEARCH_MATCH = 'nav-search-match';
|
|
1198
|
+
const NAV_SEARCH_NESTED = 'nav-search-nested';
|
|
1199
|
+
const NAV_SEARCH_HIT = 'nav-search-hit';
|
|
1200
|
+
const NAV_SEARCH_CURRENT = 'nav-search-current';
|
|
1201
|
+
|
|
1202
|
+
//: The element a menu entry keeps its text in.
|
|
1203
|
+
function navSearchLabelElement(item) {
|
|
1204
|
+
return item.querySelector ? item.querySelector('.nav-text') : null;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
//: What a menu entry says, without the icon beside it.
|
|
1208
|
+
function navSearchLabel(item) {
|
|
1209
|
+
const text = navSearchLabelElement(item);
|
|
1210
|
+
const label = (text && text.textContent) || item.textContent || '';
|
|
1211
|
+
return String(label).trim();
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
//: The children a label had before the search first marked it. The nodes are
|
|
1215
|
+
//: kept, not their markup: putting them back restores a label that carries more
|
|
1216
|
+
//: than text, and the next search marks a clean label rather than its own marks.
|
|
1217
|
+
const navSearchOriginal = new WeakMap();
|
|
1218
|
+
|
|
1219
|
+
function navSearchRestoreLabel(label) {
|
|
1220
|
+
const original = label && navSearchOriginal.get(label);
|
|
1221
|
+
if (!original) return;
|
|
1222
|
+
if (label.replaceChildren) label.replaceChildren(...original);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
//: Mark every occurrence of `query` in the label, keeping the label's own case.
|
|
1226
|
+
function navSearchMark(label, query) {
|
|
1227
|
+
if (!label || !query) return;
|
|
1228
|
+
if (!navSearchOriginal.has(label)) {
|
|
1229
|
+
navSearchOriginal.set(label, Array.from(label.childNodes || label.children || []));
|
|
1230
|
+
} else {
|
|
1231
|
+
navSearchRestoreLabel(label);
|
|
1232
|
+
}
|
|
1233
|
+
const lower = query.toLowerCase();
|
|
1234
|
+
const marked = [];
|
|
1235
|
+
navSearchOriginal.get(label).forEach(node => {
|
|
1236
|
+
const value = node.nodeValue;
|
|
1237
|
+
// An icon or a badge beside the words is put back untouched: only the
|
|
1238
|
+
// label's own text says where the match is.
|
|
1239
|
+
if (typeof value !== 'string') { marked.push(node); return; }
|
|
1240
|
+
const hay = value.toLowerCase();
|
|
1241
|
+
let at = 0;
|
|
1242
|
+
let from = 0;
|
|
1243
|
+
while (true) {
|
|
1244
|
+
const found = hay.indexOf(lower, from);
|
|
1245
|
+
if (found === -1) break;
|
|
1246
|
+
if (found > at) marked.push(document.createTextNode(value.slice(at, found)));
|
|
1247
|
+
const hit = document.createElement('mark');
|
|
1248
|
+
hit.className = NAV_SEARCH_HIT;
|
|
1249
|
+
hit.textContent = value.slice(found, found + query.length);
|
|
1250
|
+
marked.push(hit);
|
|
1251
|
+
at = found + query.length;
|
|
1252
|
+
from = at;
|
|
1253
|
+
}
|
|
1254
|
+
if (at === 0) { marked.push(node); return; }
|
|
1255
|
+
if (at < value.length) marked.push(document.createTextNode(value.slice(at)));
|
|
1256
|
+
});
|
|
1257
|
+
if (label.replaceChildren) label.replaceChildren(...marked);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
//: The typed text, comparable: padded text is not a different search.
|
|
1261
|
+
function navSearchQuery(raw) {
|
|
1262
|
+
return String(raw == null ? '' : raw).trim().toLowerCase();
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
//: How many menu levels down a match sits; 0 is a top-level section.
|
|
1266
|
+
function navSearchDepth(item, root) {
|
|
1267
|
+
let items = 0;
|
|
1268
|
+
let containers = 0;
|
|
1269
|
+
let node = item.parentElement;
|
|
1270
|
+
while (node && node !== root) {
|
|
1271
|
+
if (node.matches) {
|
|
1272
|
+
if (node.matches(NAV_SEARCH_ITEM)) items += 1;
|
|
1273
|
+
else if (node.matches(NAV_SEARCH_CHILDREN)) containers += 1;
|
|
1274
|
+
}
|
|
1275
|
+
node = node.parentElement;
|
|
1276
|
+
}
|
|
1277
|
+
// A submenu keeps its children in a container beside the parent's link, so
|
|
1278
|
+
// one level counts either the parent entry above the container or the
|
|
1279
|
+
// container itself -- never both.
|
|
1280
|
+
return Math.max(items, containers);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function navSearchContains(parent, child) {
|
|
1284
|
+
for (let node = child.parentElement; node; node = node.parentElement) {
|
|
1285
|
+
if (node === parent) return true;
|
|
1286
|
+
}
|
|
1287
|
+
return false;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function navSearchSetHidden(element, hidden) {
|
|
1291
|
+
if (element.classList) element.classList.toggle(NAV_SEARCH_HIDDEN, Boolean(hidden));
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
//: The match the arrow keys are on; it is what Enter opens.
|
|
1295
|
+
let navSearchCurrent = null;
|
|
1296
|
+
|
|
1297
|
+
//: The query the menu is showing, so a changed one drops the arrow's place.
|
|
1298
|
+
let navSearchApplied = '';
|
|
1299
|
+
|
|
1300
|
+
function navSearchMatches(root) {
|
|
1301
|
+
return root && root.querySelectorAll
|
|
1302
|
+
? Array.from(root.querySelectorAll('.' + NAV_SEARCH_MATCH))
|
|
1303
|
+
: [];
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
//: Put the keyboard's place on one match, and keep it in view.
|
|
1307
|
+
function navSearchSelect(element) {
|
|
1308
|
+
if (navSearchCurrent && navSearchCurrent.classList) {
|
|
1309
|
+
navSearchCurrent.classList.remove(NAV_SEARCH_CURRENT);
|
|
1310
|
+
}
|
|
1311
|
+
navSearchCurrent = element || null;
|
|
1312
|
+
if (!navSearchCurrent) return null;
|
|
1313
|
+
if (navSearchCurrent.classList) navSearchCurrent.classList.add(NAV_SEARCH_CURRENT);
|
|
1314
|
+
if (navSearchCurrent.scrollIntoView) {
|
|
1315
|
+
navSearchCurrent.scrollIntoView({block: 'nearest'});
|
|
1316
|
+
}
|
|
1317
|
+
return navSearchCurrent;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
//: Step through the matches on screen, wrapping at either end.
|
|
1321
|
+
function navSearchStep(step) {
|
|
1322
|
+
const items = navSearchMatches(document.getElementById(NAV_SEARCH_ROOT_ID));
|
|
1323
|
+
if (!items.length) return null;
|
|
1324
|
+
const at = items.indexOf(navSearchCurrent);
|
|
1325
|
+
const next = at === -1
|
|
1326
|
+
? (step > 0 ? 0 : items.length - 1)
|
|
1327
|
+
: (at + step + items.length) % items.length;
|
|
1328
|
+
return navSearchSelect(items[next]);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Narrow the sidebar to the entries whose text contains `rawQuery`.
|
|
1333
|
+
*
|
|
1334
|
+
* Returns the query as it was understood and how many entries matched, which is
|
|
1335
|
+
* what the status line and the tests read.
|
|
1336
|
+
*/
|
|
1337
|
+
function applyModuleSearch(rawQuery) {
|
|
1338
|
+
const root = document.getElementById(NAV_SEARCH_ROOT_ID);
|
|
1339
|
+
const query = navSearchQuery(rawQuery);
|
|
1340
|
+
const clear = document.getElementById(NAV_SEARCH_CLEAR_ID);
|
|
1341
|
+
const empty = document.getElementById(NAV_SEARCH_EMPTY_ID);
|
|
1342
|
+
if (clear) clear.hidden = query === '';
|
|
1343
|
+
if (!root || !root.querySelectorAll) {
|
|
1344
|
+
if (empty) empty.hidden = true;
|
|
1345
|
+
return {query: query, matches: 0};
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// Every label goes back to what it was before this search: the marks of the
|
|
1349
|
+
// query typed a moment ago must not survive into this one.
|
|
1350
|
+
const elements = Array.from(root.querySelectorAll(NAV_SEARCH_ITEM));
|
|
1351
|
+
elements.forEach(element => navSearchRestoreLabel(navSearchLabelElement(element)));
|
|
1352
|
+
|
|
1353
|
+
// A new query is a new set of results: the row the arrows were on belongs
|
|
1354
|
+
// to the old one, and Enter must not open a section nobody aimed at.
|
|
1355
|
+
if (query !== navSearchApplied) {
|
|
1356
|
+
navSearchApplied = query;
|
|
1357
|
+
navSearchSelect(null);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const items = elements.map(element => {
|
|
1361
|
+
const label = navSearchLabel(element);
|
|
1362
|
+
return {
|
|
1363
|
+
element: element,
|
|
1364
|
+
label: label,
|
|
1365
|
+
depth: navSearchDepth(element, root),
|
|
1366
|
+
matches: query !== '' && label.toLowerCase().indexOf(query) !== -1,
|
|
1367
|
+
};
|
|
1368
|
+
});
|
|
1369
|
+
const matches = items.filter(item => item.matches).length;
|
|
1370
|
+
items.forEach(item => {
|
|
1371
|
+
// An entry survives a search when it matches, or when a match lives
|
|
1372
|
+
// inside it and hiding it would take the match away.
|
|
1373
|
+
const keepsPath = items.some(other => other.matches && navSearchContains(item.element, other.element));
|
|
1374
|
+
const visible = query === '' || item.matches || keepsPath;
|
|
1375
|
+
navSearchSetHidden(item.element, !visible);
|
|
1376
|
+
if (item.element.classList) {
|
|
1377
|
+
item.element.classList.toggle(NAV_SEARCH_MATCH, query !== '' && item.matches);
|
|
1378
|
+
item.element.classList.toggle(NAV_SEARCH_NESTED,
|
|
1379
|
+
query !== '' && item.matches && item.depth > 0);
|
|
1380
|
+
}
|
|
1381
|
+
if (item.element.style && item.element.style.setProperty) {
|
|
1382
|
+
item.element.style.setProperty('--nav-search-depth', String(item.depth));
|
|
1383
|
+
}
|
|
1384
|
+
// The result says where it matched: only the substring is marked, in
|
|
1385
|
+
// the case the label was written in, and the rest of the label stays.
|
|
1386
|
+
if (query !== '' && item.matches) {
|
|
1387
|
+
navSearchMark(navSearchLabelElement(item.element), query);
|
|
1388
|
+
}
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
// A match that this search took away cannot stay where the arrows were.
|
|
1392
|
+
if (navSearchCurrent && !items.some(item => item.element === navSearchCurrent && item.matches)) {
|
|
1393
|
+
navSearchSelect(null);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
if (empty) {
|
|
1397
|
+
empty.hidden = !(query !== '' && matches === 0);
|
|
1398
|
+
if (!empty.hidden) empty.textContent = 'No modules match "' + query + '"';
|
|
1399
|
+
}
|
|
1400
|
+
return {query: query, matches: matches};
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
//: The clear button and Escape put the menu back the way it was.
|
|
1404
|
+
function clearModuleSearch() {
|
|
1405
|
+
const input = document.getElementById(NAV_SEARCH_INPUT_ID);
|
|
1406
|
+
if (input) input.value = '';
|
|
1407
|
+
return applyModuleSearch('');
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function setupModuleSearch() {
|
|
1411
|
+
const input = document.getElementById(NAV_SEARCH_INPUT_ID);
|
|
1412
|
+
if (!input) return;
|
|
1413
|
+
|
|
1414
|
+
const update = () => applyModuleSearch(input.value);
|
|
1415
|
+
input.addEventListener('input', update);
|
|
1416
|
+
// The browser's own clear button of a type="search" field does not fire
|
|
1417
|
+
// "input" everywhere; "search" is the event it does fire.
|
|
1418
|
+
input.addEventListener('search', update);
|
|
1419
|
+
input.addEventListener('keydown', event => {
|
|
1420
|
+
if (event.key === 'Escape') {
|
|
1421
|
+
if (input.value === '') return;
|
|
1422
|
+
event.preventDefault();
|
|
1423
|
+
clearModuleSearch();
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
1427
|
+
const matches = navSearchMatches(document.getElementById(NAV_SEARCH_ROOT_ID));
|
|
1428
|
+
if (!matches.length) return;
|
|
1429
|
+
// The arrows belong to the results while the field is focused: the
|
|
1430
|
+
// caret stays where it was, and the menu takes the key instead.
|
|
1431
|
+
event.preventDefault();
|
|
1432
|
+
navSearchStep(event.key === 'ArrowDown' ? 1 : -1);
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
if (event.key === 'Enter') {
|
|
1436
|
+
const chosen = navSearchCurrent
|
|
1437
|
+
|| navSearchMatches(document.getElementById(NAV_SEARCH_ROOT_ID))[0];
|
|
1438
|
+
if (chosen && chosen.click) {
|
|
1439
|
+
event.preventDefault();
|
|
1440
|
+
chosen.click();
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
const clear = document.getElementById(NAV_SEARCH_CLEAR_ID);
|
|
1446
|
+
if (clear) {
|
|
1447
|
+
clear.addEventListener('click', () => {
|
|
1448
|
+
clearModuleSearch();
|
|
1449
|
+
input.focus();
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// Sections add their entries as they initialise, which can happen after the
|
|
1454
|
+
// field is on screen; a search typed meanwhile has to reach them too.
|
|
1455
|
+
const root = document.getElementById(NAV_SEARCH_ROOT_ID);
|
|
1456
|
+
if (root && typeof MutationObserver !== 'undefined') {
|
|
1457
|
+
new MutationObserver(() => {
|
|
1458
|
+
if (input.value.trim() !== '') applyModuleSearch(input.value);
|
|
1459
|
+
}).observe(root, {childList: true, subtree: true});
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
update();
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// What a section -- or a test -- may ask of the field without owning it.
|
|
1466
|
+
window.AppNavSearch = {
|
|
1467
|
+
apply: applyModuleSearch,
|
|
1468
|
+
clear: clearModuleSearch,
|
|
1469
|
+
label: navSearchLabel,
|
|
1470
|
+
depth: navSearchDepth,
|
|
1471
|
+
};
|
|
1472
|
+
// --- end of the search field of the sidebar
|
|
1473
|
+
|
|
1127
1474
|
const appSections = {
|
|
1128
1475
|
sections: new Map(),
|
|
1129
1476
|
|
|
@@ -1177,11 +1524,10 @@ function setupAuthForms() {
|
|
|
1177
1524
|
});
|
|
1178
1525
|
}
|
|
1179
1526
|
|
|
1180
|
-
// Forgot password
|
|
1181
|
-
//
|
|
1182
|
-
//
|
|
1183
|
-
//
|
|
1184
|
-
// with no destination, rather than a broken address.
|
|
1527
|
+
// Forgot password: the address, when the deployment has one, comes from
|
|
1528
|
+
// the server. The framework does not know where a password is reset, and
|
|
1529
|
+
// hardcoding somebody's page into the shell would keep two truths. Without
|
|
1530
|
+
// an answer the line stays hidden rather than showing a broken link.
|
|
1185
1531
|
applyForgotPasswordLink();
|
|
1186
1532
|
|
|
1187
1533
|
// Updated sign-in form handler
|
|
@@ -1251,12 +1597,24 @@ function registerAppSections() {
|
|
|
1251
1597
|
);
|
|
1252
1598
|
}
|
|
1253
1599
|
|
|
1254
|
-
// The token lives
|
|
1255
|
-
// starts getting refusals in the middle of the work. The server
|
|
1256
|
-
//
|
|
1257
|
-
//
|
|
1258
|
-
|
|
1600
|
+
// The access token lives an hour and expires silently: without this the panel
|
|
1601
|
+
// simply starts getting refusals in the middle of the work. The server says how
|
|
1602
|
+
// long the token has at sign-in and on every renewal, and the next one is
|
|
1603
|
+
// bought five minutes before the end -- while the token is still valid, which
|
|
1604
|
+
// is the only time the exchange is allowed. A tab that was asleep for longer
|
|
1605
|
+
// (a laptop that closed its lid) is picked up by the wake handlers below: a
|
|
1606
|
+
// session that was asked to stay signed in renews from its own credential even
|
|
1607
|
+
// after the token has gone (keepup-177).
|
|
1608
|
+
const SESSION_REFRESH_LEAD_SECONDS = 300;
|
|
1609
|
+
const SESSION_RETRY_SECONDS = 60;
|
|
1259
1610
|
let sessionRefreshTimer = null;
|
|
1611
|
+
let sessionExpiresAt = null;
|
|
1612
|
+
let sessionRefreshing = false;
|
|
1613
|
+
|
|
1614
|
+
function noteSessionLifetime(expiresInSeconds) {
|
|
1615
|
+
const seconds = Number(expiresInSeconds);
|
|
1616
|
+
sessionExpiresAt = seconds > 0 ? Date.now() + seconds * 1000 : null;
|
|
1617
|
+
}
|
|
1260
1618
|
|
|
1261
1619
|
function scheduleSessionRefresh(expiresInSeconds) {
|
|
1262
1620
|
if (sessionRefreshTimer) {
|
|
@@ -1266,43 +1624,101 @@ function scheduleSessionRefresh(expiresInSeconds) {
|
|
|
1266
1624
|
if (!expiresInSeconds) {
|
|
1267
1625
|
return;
|
|
1268
1626
|
}
|
|
1627
|
+
noteSessionLifetime(expiresInSeconds);
|
|
1269
1628
|
// Never sooner than a minute: a token that short is about to be replaced
|
|
1270
|
-
// by a
|
|
1271
|
-
const delay = Math.max(60, expiresInSeconds - SESSION_REFRESH_LEAD_SECONDS);
|
|
1272
|
-
sessionRefreshTimer = setTimeout(refreshSession, delay * 1000);
|
|
1629
|
+
// by a sign-in anyway, and a tight timer would spin.
|
|
1630
|
+
const delay = Math.max(60, Number(expiresInSeconds) - SESSION_REFRESH_LEAD_SECONDS);
|
|
1631
|
+
sessionRefreshTimer = setTimeout(function () { refreshSession(); }, delay * 1000);
|
|
1273
1632
|
}
|
|
1274
1633
|
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1634
|
+
function forgetSession() {
|
|
1635
|
+
localStorage.removeItem('authToken');
|
|
1636
|
+
localStorage.removeItem('auth_token');
|
|
1637
|
+
if (window.MfaPanel) MfaPanel.clearChallenge();
|
|
1638
|
+
if (sessionRefreshTimer) {
|
|
1639
|
+
clearTimeout(sessionRefreshTimer);
|
|
1640
|
+
sessionRefreshTimer = null;
|
|
1278
1641
|
}
|
|
1642
|
+
sessionExpiresAt = null;
|
|
1643
|
+
currentUser = null;
|
|
1644
|
+
headerBadge = null;
|
|
1645
|
+
updateUIAfterAuth();
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
async function refreshSession(showSignIn = true) {
|
|
1649
|
+
if (sessionRefreshing) {
|
|
1650
|
+
return false;
|
|
1651
|
+
}
|
|
1652
|
+
sessionRefreshing = true;
|
|
1279
1653
|
try {
|
|
1280
1654
|
const response = await fetch('/api/auth/refresh', { method: 'POST' });
|
|
1281
|
-
if (
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1655
|
+
if (response.ok) {
|
|
1656
|
+
const data = await response.json();
|
|
1657
|
+
if (data.access_token) {
|
|
1658
|
+
// The renewed session is already in the cookie; the body's
|
|
1659
|
+
// token is for agents and is not kept.
|
|
1660
|
+
localStorage.setItem('authToken', SESSION_MARKER);
|
|
1661
|
+
scheduleSessionRefresh(data.expires_in);
|
|
1662
|
+
if (!currentUser) {
|
|
1663
|
+
// A renewal that came back after a retry (a server that was
|
|
1664
|
+
// down while the page loaded) still has to show the panel.
|
|
1665
|
+
return await verifyToken();
|
|
1666
|
+
}
|
|
1667
|
+
return true;
|
|
1668
|
+
}
|
|
1286
1669
|
}
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
//
|
|
1290
|
-
|
|
1291
|
-
|
|
1670
|
+
if (response.status === 401) {
|
|
1671
|
+
// There is nothing left to renew, not even the credential a
|
|
1672
|
+
// remembered session carries: asking for the sign-in is honest.
|
|
1673
|
+
forgetSession();
|
|
1674
|
+
if (showSignIn) showAuthModal();
|
|
1675
|
+
return false;
|
|
1292
1676
|
}
|
|
1677
|
+
// A server that is down, or a proxy answering for it, is not the end of
|
|
1678
|
+
// the session: try again rather than letting the token run out.
|
|
1679
|
+
scheduleSessionRefresh(SESSION_RETRY_SECONDS);
|
|
1680
|
+
return false;
|
|
1293
1681
|
} catch (error) {
|
|
1294
1682
|
console.warn('Failed to refresh session:', error);
|
|
1683
|
+
scheduleSessionRefresh(SESSION_RETRY_SECONDS);
|
|
1684
|
+
return false;
|
|
1685
|
+
} finally {
|
|
1686
|
+
sessionRefreshing = false;
|
|
1295
1687
|
}
|
|
1296
1688
|
}
|
|
1297
1689
|
|
|
1690
|
+
// A tab whose timers were frozen (a closed lid, a phone that slept) wakes up
|
|
1691
|
+
// after the token has already expired. The timer cannot have fired in time, so
|
|
1692
|
+
// the wake itself asks for the next token: the cookie is sent, and a session
|
|
1693
|
+
// that was asked to stay signed in renews without a password (keepup-177).
|
|
1694
|
+
function installSessionKeepAlive() {
|
|
1695
|
+
const wake = function () {
|
|
1696
|
+
if (!localStorage.getItem('authToken')) {
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
if (sessionExpiresAt === null ||
|
|
1700
|
+
Date.now() >= sessionExpiresAt - SESSION_REFRESH_LEAD_SECONDS * 1000) {
|
|
1701
|
+
refreshSession();
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1704
|
+
document.addEventListener('visibilitychange', function () {
|
|
1705
|
+
if (!document.hidden) wake();
|
|
1706
|
+
});
|
|
1707
|
+
window.addEventListener('focus', wake);
|
|
1708
|
+
window.addEventListener('online', wake);
|
|
1709
|
+
}
|
|
1710
|
+
// --- end of the panel session renewal ---------------------------------------
|
|
1711
|
+
|
|
1298
1712
|
async function login(username, password) {
|
|
1713
|
+
const rememberBox = document.getElementById('loginRemember');
|
|
1714
|
+
const remember = !!(rememberBox && rememberBox.checked);
|
|
1299
1715
|
try {
|
|
1300
1716
|
const response = await fetch('/api/auth/login', {
|
|
1301
1717
|
method: 'POST',
|
|
1302
1718
|
headers: {
|
|
1303
1719
|
'Content-Type': 'application/json'
|
|
1304
1720
|
},
|
|
1305
|
-
body: JSON.stringify({ username, password })
|
|
1721
|
+
body: JSON.stringify({ username, password, remember })
|
|
1306
1722
|
});
|
|
1307
1723
|
|
|
1308
1724
|
const data = await response.json();
|
|
@@ -1568,18 +1984,17 @@ window.AppHeader = {
|
|
|
1568
1984
|
|
|
1569
1985
|
async function logout() {
|
|
1570
1986
|
// The server ends the session first: forgetting it only in this browser
|
|
1571
|
-
// would leave a copied token working
|
|
1987
|
+
// would leave a copied token working -- and a remembered session's renewal
|
|
1988
|
+
// credential working with it (keepup-177).
|
|
1572
1989
|
try {
|
|
1573
1990
|
await fetch('/api/auth/logout', { method: 'POST' });
|
|
1574
1991
|
} catch (error) {
|
|
1575
1992
|
console.warn('Could not end the session on the server:', error);
|
|
1576
1993
|
}
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
currentUser = null;
|
|
1994
|
+
forgetSession();
|
|
1995
|
+
// The corner belongs to whoever is signed in; the helper clears it for an
|
|
1996
|
+
// expired session too, and signing out is its own way there.
|
|
1581
1997
|
headerBadge = null;
|
|
1582
|
-
updateUIAfterAuth();
|
|
1583
1998
|
showAuthModal();
|
|
1584
1999
|
hideVersionPanel();
|
|
1585
2000
|
}
|
|
@@ -1594,6 +2009,12 @@ async function verifyToken() {
|
|
|
1594
2009
|
localStorage.setItem('authToken', SESSION_MARKER);
|
|
1595
2010
|
currentUser = data.user;
|
|
1596
2011
|
updateUIAfterAuth();
|
|
2012
|
+
// The server says how long the token still has, so a panel opened
|
|
2013
|
+
// on an hour-old session schedules its renewal at the right moment
|
|
2014
|
+
// instead of guessing (keepup-177).
|
|
2015
|
+
if (typeof data.expires_in === 'number') {
|
|
2016
|
+
scheduleSessionRefresh(data.expires_in);
|
|
2017
|
+
}
|
|
1597
2018
|
return true;
|
|
1598
2019
|
} else {
|
|
1599
2020
|
localStorage.removeItem('authToken');
|
|
@@ -2092,25 +2513,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
2092
2513
|
});
|
|
2093
2514
|
|
|
2094
2515
|
/**
|
|
2095
|
-
* The "forgot password" link
|
|
2096
|
-
*
|
|
2097
|
-
*
|
|
2516
|
+
* The "forgot password" link, when the deployment has somewhere to send people.
|
|
2517
|
+
*
|
|
2518
|
+
* The address is the application's: the framework knows nothing about where a
|
|
2519
|
+
* password is reset, and named no page of its own. Without one the line stays
|
|
2520
|
+
* out of sight rather than offering a link that goes nowhere (keepup-178).
|
|
2098
2521
|
*/
|
|
2099
2522
|
function applyForgotPasswordLink() {
|
|
2100
2523
|
const link = document.getElementById('forgotPasswordLink');
|
|
2524
|
+
const hint = document.getElementById('forgotPasswordHint');
|
|
2101
2525
|
if (!link) return;
|
|
2102
2526
|
loadPublicConfig().then(config => {
|
|
2103
|
-
// The whole address comes from the server: building it here would mean
|
|
2104
|
-
// hardcoding someone else's domain into a panel that loads nothing of
|
|
2105
|
-
// its own. Where it comes from is the application's business: for one
|
|
2106
|
-
// it's a Telegram bot, for another its own page, for a third there's
|
|
2107
|
-
// no reset at all, and then the link stays plain text. Before task
|
|
2108
|
-
// keepup-27 the shell asked for the address directly at the bot's
|
|
2109
|
-
// route, meaning the framework knew about the bot (keepup-27).
|
|
2110
2527
|
const url = config && config.password_reset_url;
|
|
2111
2528
|
if (!url) return;
|
|
2112
2529
|
link.href = url;
|
|
2113
2530
|
link.target = '_blank';
|
|
2114
2531
|
link.rel = 'noopener noreferrer';
|
|
2532
|
+
if (hint) hint.classList.remove('hidden');
|
|
2115
2533
|
}).catch(() => { /* without an address the sign-in page works as before */ });
|
|
2116
2534
|
}
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "keepup-http"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.0"
|
|
8
8
|
description = "The way into the application over HTTP: the application, its routes and its middleware."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|