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.
Files changed (49) hide show
  1. {keepup_http-0.6.0 → keepup_http-0.7.0}/PKG-INFO +1 -1
  2. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/__init__.py +1 -1
  3. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/factory.py +5 -0
  4. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/css/main_nebula.css +22 -0
  5. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/css/main_new.css +173 -0
  6. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/index_nebula.html +29 -14
  7. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/index_new.html +29 -14
  8. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/js/main_new.js +479 -61
  9. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http.egg-info/PKG-INFO +1 -1
  10. {keepup_http-0.6.0 → keepup_http-0.7.0}/pyproject.toml +1 -1
  11. {keepup_http-0.6.0 → keepup_http-0.7.0}/README.md +0 -0
  12. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/api_docs.py +0 -0
  13. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/body_limit.py +0 -0
  14. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/events_api.py +0 -0
  15. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/lock_routes.py +0 -0
  16. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/plugin.py +0 -0
  17. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/routes.py +0 -0
  18. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/css/aos.css +0 -0
  19. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/css/tailwind.css +0 -0
  20. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/js/aos.js +0 -0
  21. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/js/feather-icons.js +0 -0
  22. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/js/layout_bootstrap.js +0 -0
  23. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/js/mfa_panel.js +0 -0
  24. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/js/tailwind.js +0 -0
  25. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/background_tasks.css +0 -0
  26. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/cluster.css +0 -0
  27. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/event_manager.css +0 -0
  28. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/integration_logs.css +0 -0
  29. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/metrics.css +0 -0
  30. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/modules.css +0 -0
  31. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/themes.css +0 -0
  32. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/users.css +0 -0
  33. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/background_tasks.js +0 -0
  34. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/chart.js +0 -0
  35. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +0 -0
  36. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/cluster.js +0 -0
  37. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/event_manager.js +0 -0
  38. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/integration_logs.js +0 -0
  39. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/metrics.js +0 -0
  40. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/modules.js +0 -0
  41. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/themes.js +0 -0
  42. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/users.js +0 -0
  43. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http/web.py +0 -0
  44. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http.egg-info/SOURCES.txt +0 -0
  45. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http.egg-info/dependency_links.txt +0 -0
  46. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http.egg-info/entry_points.txt +0 -0
  47. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http.egg-info/requires.txt +0 -0
  48. {keepup_http-0.6.0 → keepup_http-0.7.0}/keepup_http.egg-info/top_level.txt +0 -0
  49. {keepup_http-0.6.0 → keepup_http-0.7.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepup-http
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: The way into the application over HTTP: the application, its routes and its middleware.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -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.6.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-6">
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
- <p class="mt-3 text-center text-sm text-gray-500">
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">Ask the bot</a>
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-6">
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
- <p class="mt-3 text-center text-sm text-gray-500">
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">Ask the bot</a>
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
- // 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'];
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 readCsrfCookie() {
33
- for (const name of CSRF_COOKIES) {
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
- 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);
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
- return verifyToken();
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
- let publicConfigPromise = null;
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 landing page, which sends people to the bot.
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 (task 272): the link leads to the bot, and the reset is
1181
- // issued by it. The bot's name comes from the server -- it differs between
1182
- // the stand and production, and hardcoding it into the markup would mean
1183
- // keeping two truths. If the server doesn't answer, the link stays a hint
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 a day and expires silently: without this the panel simply
1255
- // starts getting refusals in the middle of the work. The server tells how long
1256
- // it has at login, and the session is exchanged an hour before the end -- while
1257
- // the token is still valid, which is the only time the exchange is allowed.
1258
- const SESSION_REFRESH_LEAD_SECONDS = 3600;
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 login anyway, and a tight timer would spin.
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
- async function refreshSession() {
1276
- if (!localStorage.getItem('authToken')) {
1277
- return;
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 (!response.ok) {
1282
- // Nothing to do about it here: the next request will be refused and
1283
- // the panel asks for a login as before.
1284
- console.warn('Failed to refresh session:', response.status);
1285
- return;
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
- const data = await response.json();
1288
- if (data.access_token) {
1289
- // The renewed session is already in the cookie; the body's token
1290
- // is for agents and is not kept.
1291
- scheduleSessionRefresh(data.expires_in);
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
- localStorage.removeItem('authToken');
1578
- localStorage.removeItem('auth_token');
1579
- if (window.MfaPanel) MfaPanel.clearChallenge();
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 leads to the bot: the reset is issued by it,
2096
- * not this page (task 272). The bot's name is known to the server; without
2097
- * a response the link stays plain text.
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
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepup-http
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: The way into the application over HTTP: the application, its routes and its middleware.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "keepup-http"
7
- version = "0.6.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