keepup-http 0.5.0__tar.gz → 0.6.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.5.0 → keepup_http-0.6.0}/PKG-INFO +1 -1
  2. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/__init__.py +1 -1
  3. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/factory.py +14 -0
  4. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/routes.py +219 -89
  5. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/index_nebula.html +2 -0
  6. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/index_new.html +2 -0
  7. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/js/main_new.js +64 -24
  8. keepup_http-0.6.0/keepup_http/static/js/mfa_panel.js +342 -0
  9. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http.egg-info/PKG-INFO +1 -1
  10. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http.egg-info/SOURCES.txt +1 -0
  11. {keepup_http-0.5.0 → keepup_http-0.6.0}/pyproject.toml +1 -1
  12. {keepup_http-0.5.0 → keepup_http-0.6.0}/README.md +0 -0
  13. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/api_docs.py +0 -0
  14. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/body_limit.py +0 -0
  15. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/events_api.py +0 -0
  16. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/lock_routes.py +0 -0
  17. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/plugin.py +0 -0
  18. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/css/aos.css +0 -0
  19. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/css/main_nebula.css +0 -0
  20. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/css/main_new.css +0 -0
  21. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/css/tailwind.css +0 -0
  22. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/js/aos.js +0 -0
  23. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/js/feather-icons.js +0 -0
  24. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/js/layout_bootstrap.js +0 -0
  25. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/js/tailwind.js +0 -0
  26. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/css/background_tasks.css +0 -0
  27. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/css/cluster.css +0 -0
  28. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/css/event_manager.css +0 -0
  29. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/css/integration_logs.css +0 -0
  30. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/css/metrics.css +0 -0
  31. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/css/modules.css +0 -0
  32. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/css/themes.css +0 -0
  33. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/css/users.css +0 -0
  34. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/background_tasks.js +0 -0
  35. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/chart.js +0 -0
  36. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +0 -0
  37. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/cluster.js +0 -0
  38. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/event_manager.js +0 -0
  39. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/integration_logs.js +0 -0
  40. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/metrics.js +0 -0
  41. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/modules.js +0 -0
  42. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/themes.js +0 -0
  43. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/users.js +0 -0
  44. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/web.py +0 -0
  45. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http.egg-info/dependency_links.txt +0 -0
  46. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http.egg-info/entry_points.txt +0 -0
  47. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http.egg-info/requires.txt +0 -0
  48. {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http.egg-info/top_level.txt +0 -0
  49. {keepup_http-0.5.0 → keepup_http-0.6.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepup-http
3
- Version: 0.5.0
3
+ Version: 0.6.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.5.0"
13
+ __version__ = "0.6.0"
14
14
 
15
15
  __all__ = ["HttpPlugin", "SERVICE_HTTP", "register_api_documentation",
16
16
  "register_event_api_routes"]
@@ -43,6 +43,7 @@ from keepup_audit.events import init_event_manager
43
43
  from keepup_http.events_api import register_event_api_routes
44
44
  from keepup.instance import get_instance_id
45
45
  from keepup_http.lock_routes import register_lock_routes
46
+ from keepup_http.routes import register_declared_routes, register_declared_sockets
46
47
  from keepup_metrics.api import register_metrics_routes
47
48
  from keepup_modules.catalogue import register_module_routes
48
49
  from keepup.plugins.registry import (
@@ -314,6 +315,19 @@ def _build_lifespan(settings: KeepupSettings):
314
315
  if manager is not None:
315
316
  await initialize_plugins(app, manager, config_path=settings.plugins_config_path)
316
317
 
318
+ # What the kernel's plugins declared is bound here (keepup-121): an
319
+ # application that named only `plugins_dir` used to get no route of its
320
+ # own, because only a PluginManager of the 0.3.0 path ever bound them.
321
+ # The manager above registered its declarations a moment ago; a route the
322
+ # application already answers is skipped rather than answered twice.
323
+ if kernel is not None:
324
+ mounted = kernel.mount({
325
+ "routes": lambda items, runtime=None: register_declared_routes(app, items),
326
+ "sockets": lambda items, runtime=None: register_declared_sockets(app, items),
327
+ })
328
+ if mounted:
329
+ logger.info("Mounted what the plugins declared: %s", mounted)
330
+
317
331
  # The log handlers the running capabilities contribute -- the shipping
318
332
  # one, when a collector is named (keepup-118). A deployment that
319
333
  # installs none attaches nothing.
@@ -40,6 +40,8 @@ from keepup.plugins import route_mask
40
40
  __all__ = [
41
41
  "accepted_params",
42
42
  "raw_request_wrapper",
43
+ "register_declared_routes",
44
+ "register_declared_sockets",
43
45
  "register_plugin_routes",
44
46
  ]
45
47
 
@@ -335,10 +337,9 @@ def create_wrapper(handler, path, methods, require_auth: bool = True,
335
337
 
336
338
  async def call(request: Request, current_user):
337
339
  """One call of a plugin route, from the audit's first row to its last."""
338
- request_data = {
339
- 'user_id': current_user.get('id') if current_user else None,
340
- 'username': current_user.get('username') if current_user else 'anonymous',
341
- }
340
+ # The actor is not part of request_data: that is redacted whole, and a
341
+ # row that cannot name who called is not an audit (keepup-172).
342
+ request_data = {}
342
343
  if has_path_params:
343
344
  request_data['path_params'] = dict(request.path_params)
344
345
  request_data['query_params'] = dict(request.query_params)
@@ -353,7 +354,8 @@ def create_wrapper(handler, path, methods, require_auth: bool = True,
353
354
  method=method_name,
354
355
  endpoint=path,
355
356
  host='plugin_api',
356
- request_data=request_data
357
+ request_data=request_data,
358
+ actor=current_user
357
359
  ) as request_id:
358
360
  try:
359
361
  response = await invoke(request, current_user)
@@ -413,95 +415,223 @@ async def register_plugin_routes(app, manager):
413
415
  logger.info(f"Found {len(routes)} plugin routes to register")
414
416
 
415
417
  for route in routes:
416
- handler = route['handler']
417
- path = route['path']
418
- methods = route['methods']
419
- require_auth = route.get('require_auth', True)
420
- is_upload = route.get('is_upload', False)
421
- permission = route.get('permission')
422
- audit = route.get('audit', True)
423
- response_media_type = route.get('response_media_type')
424
- if response_media_type is not None and (
425
- not isinstance(response_media_type, str) or not response_media_type.strip()):
426
- raise ValueError(
427
- f"{path}: response_media_type is a media type, like 'text/plain'")
428
- if response_media_type is not None and route.get('raw_request'):
429
- raise ValueError(
430
- f"{path}: a raw_request route answers with a Response of its own; "
431
- "response_media_type would be ignored. Drop one of the two.")
432
- if permission is not None:
433
- # A right needs somebody to hold it: on a route nobody signs in to,
434
- # or one that signs itself in, it would be a check that never runs.
435
- if not isinstance(permission, str) or not permission.strip():
436
- raise ValueError(f"{path}: permission must be a non-empty string")
437
- if route.get('raw_request') or not require_auth:
438
- raise ValueError(
439
- f"{path}: a route that declares a permission must be signed in by "
440
- "the framework -- drop require_auth: false or raw_request, or the "
441
- "permission")
442
-
443
- logger.info(
444
- f"Registering route {path} - methods: {methods}, auth: {require_auth}, upload: {is_upload}")
445
-
446
- # The kernel parsed and checked the mask when it collected the route,
447
- # for every transport there will ever be (keepup-102); a route that
448
- # reaches here without one was declared by something else, and is
449
- # checked the same way rather than trusted.
450
- mask = route.get('_mask')
451
- if mask is None:
452
- try:
453
- mask = route_mask.parse(route.get(route_mask.MASK_FIELD), path)
454
- route_mask.check_signature(handler, mask)
455
- except route_mask.MaskError as error:
456
- # The same loudness as the raw_request check below: a mask naming
457
- # a parameter the handler does not take is a typo, and finding it
458
- # on a request would mean hearing about it from a user.
459
- raise ValueError(f"{path}: {error}")
460
-
461
- if route.get('raw_request'):
462
- # A raw route authenticates inside the handler -- it is handed the
463
- # Request and nothing else. Declaring require_auth on it therefore
464
- # promises a check the framework does not make, and the start-up
465
- # line used to print auth=True over an open route (keepup-12).
466
- if route.get('require_auth') is True:
467
- raise ValueError(
468
- f"{path}: a raw_request route cannot also declare "
469
- "require_auth -- the handler receives the Request and "
470
- "authenticates itself. Drop one of the two.")
471
- require_auth = False
472
- wrapper = raw_request_wrapper(handler)
473
- else:
474
- wrapper = create_wrapper(handler, path, methods, require_auth, is_upload, mask,
475
- permission, audit=audit,
476
- response_media_type=response_media_type)
477
-
478
- # What the route says about its body: its own limit, or that it reads
479
- # the body itself (keepup/body_limit.py).
480
- declare_limit(wrapper, route)
481
-
482
- app.add_api_route(
483
- path,
484
- wrapper,
485
- methods=methods,
486
- include_in_schema=route.get('include_in_schema', True)
487
- )
488
-
489
- logger.info(
490
- f"Registered plugin route: {path} - {methods} (auth={require_auth}, upload={is_upload})")
418
+ _mount_route(app, route)
491
419
 
492
420
  websocket_routes = manager.get_all_websocket_routes()
493
421
  logger.info(f"Found {len(websocket_routes)} WebSocket plugin routes to register")
494
422
 
495
423
  for ws_route in websocket_routes:
496
- path = ws_route['path']
497
- handler = ws_route['handler']
498
- # Off by default, unlike HTTP routes: sockets of agents and guests carry
499
- # tokens of their own and would be refused by the panel's sign-in rule.
500
- if ws_route.get('require_auth', False):
501
- handler = signed_in_websocket(handler)
502
- _register_websocket(app, path, handler)
503
- logger.info(f"Registered WebSocket plugin route: {path} "
504
- f"(auth={bool(ws_route.get('require_auth', False))})")
424
+ _mount_socket(app, ws_route)
425
+
426
+
427
+ def register_declared_routes(app, declarations) -> int:
428
+ """Mount the routes the kernel collected from the plugins (keepup-121).
429
+
430
+ The kernel resolves, loads and initialises the plugins, and what they
431
+ declared is the runtime's, transport-neutral: this is the HTTP half of that
432
+ seam, the call ``runtime.mount`` hands to a registrar. Until it existed an
433
+ application that named only ``plugins_dir`` got no route of its own, because
434
+ only a PluginManager of the 0.3.0 path ever bound them.
435
+
436
+ What the application already answers is left alone: the framework's own
437
+ routes are registered before the kernel starts, and an application that
438
+ passes a manager has its declarations bound by `register_plugin_routes` --
439
+ so one declaration never becomes two endpoints.
440
+
441
+ Args:
442
+ app: the FastAPI application.
443
+ declarations: the route declarations the kernel collected, each tagged
444
+ with ``_plugin_id`` and, because the collector does it once for every
445
+ transport, a parsed ``_mask``.
446
+
447
+ Returns:
448
+ How many declarations were mounted.
449
+ """
450
+ mounted = 0
451
+ for declaration in declarations:
452
+ path = declaration["path"]
453
+ methods = tuple(declaration.get("methods") or ())
454
+ if _already_mounted(app, path, methods):
455
+ logger.info("Route %s %s already answers: the kernel's declaration is "
456
+ "not mounted twice", path, list(methods))
457
+ continue
458
+ _mount_route(app, declaration)
459
+ mounted += 1
460
+ return mounted
461
+
462
+
463
+ def register_declared_sockets(app, declarations) -> int:
464
+ """Mount the sockets the kernel collected from the plugins (keepup-121).
465
+
466
+ The same seam as `register_declared_routes`, for the other thing a plugin
467
+ declares. A socket the application already answers is left alone.
468
+
469
+ Args:
470
+ app: the FastAPI application.
471
+ declarations: the socket declarations the kernel collected.
472
+
473
+ Returns:
474
+ How many declarations were mounted.
475
+ """
476
+ mounted = 0
477
+ for declaration in declarations:
478
+ path = declaration["path"]
479
+ if _already_mounted(app, path):
480
+ logger.info("Socket %s already answers: the kernel's declaration is not "
481
+ "mounted twice", path)
482
+ continue
483
+ _mount_socket(app, declaration)
484
+ mounted += 1
485
+ return mounted
486
+
487
+
488
+ def _mount_route(app, declaration):
489
+ """Turn one declared route into an endpoint, having checked it first.
490
+
491
+ One implementation for every declaration, whatever collected it -- the
492
+ manager of the 0.3.0 path or the kernel's catalogue (keepup-21, keepup-121).
493
+ A declaration the route runtime refuses -- a permission on a route nobody
494
+ signs in to, a mask naming a parameter the handler does not take -- is a
495
+ mistake found at the start, rather than a route that quietly goes missing
496
+ (keepup-102).
497
+
498
+ Args:
499
+ app: the FastAPI application.
500
+ declaration: the route as the plugin declared it. The kernel's collector
501
+ leaves a parsed ``_mask`` on it; a declaration from anywhere else is
502
+ checked here instead of trusted.
503
+
504
+ Raises:
505
+ ValueError: when the declaration is one the runtime refuses.
506
+ """
507
+ route = declaration
508
+ handler = route['handler']
509
+ path = route['path']
510
+ methods = route['methods']
511
+ require_auth = route.get('require_auth', True)
512
+ is_upload = route.get('is_upload', False)
513
+ permission = route.get('permission')
514
+ audit = route.get('audit', True)
515
+ response_media_type = route.get('response_media_type')
516
+ if response_media_type is not None and (
517
+ not isinstance(response_media_type, str) or not response_media_type.strip()):
518
+ raise ValueError(
519
+ f"{path}: response_media_type is a media type, like 'text/plain'")
520
+ if response_media_type is not None and route.get('raw_request'):
521
+ raise ValueError(
522
+ f"{path}: a raw_request route answers with a Response of its own; "
523
+ "response_media_type would be ignored. Drop one of the two.")
524
+ if permission is not None:
525
+ # A right needs somebody to hold it: on a route nobody signs in to,
526
+ # or one that signs itself in, it would be a check that never runs.
527
+ if not isinstance(permission, str) or not permission.strip():
528
+ raise ValueError(f"{path}: permission must be a non-empty string")
529
+ if route.get('raw_request') or not require_auth:
530
+ raise ValueError(
531
+ f"{path}: a route that declares a permission must be signed in by "
532
+ "the framework -- drop require_auth: false or raw_request, or the "
533
+ "permission")
534
+
535
+ logger.info(
536
+ f"Registering route {path} - methods: {methods}, auth: {require_auth}, upload: {is_upload}")
537
+
538
+ # The kernel parsed and checked the mask when it collected the route,
539
+ # for every transport there will ever be (keepup-102); a route that
540
+ # reaches here without one was declared by something else, and is
541
+ # checked the same way rather than trusted.
542
+ mask = route.get('_mask')
543
+ if mask is None:
544
+ try:
545
+ mask = route_mask.parse(route.get(route_mask.MASK_FIELD), path)
546
+ route_mask.check_signature(handler, mask)
547
+ except route_mask.MaskError as error:
548
+ # The same loudness as the raw_request check below: a mask naming
549
+ # a parameter the handler does not take is a typo, and finding it
550
+ # on a request would mean hearing about it from a user.
551
+ raise ValueError(f"{path}: {error}")
552
+
553
+ if route.get('raw_request'):
554
+ # A raw route authenticates inside the handler -- it is handed the
555
+ # Request and nothing else. Declaring require_auth on it therefore
556
+ # promises a check the framework does not make, and the start-up
557
+ # line used to print auth=True over an open route (keepup-12).
558
+ if route.get('require_auth') is True:
559
+ raise ValueError(
560
+ f"{path}: a raw_request route cannot also declare "
561
+ "require_auth -- the handler receives the Request and "
562
+ "authenticates itself. Drop one of the two.")
563
+ require_auth = False
564
+ wrapper = raw_request_wrapper(handler)
565
+ else:
566
+ wrapper = create_wrapper(handler, path, methods, require_auth, is_upload, mask,
567
+ permission, audit=audit,
568
+ response_media_type=response_media_type)
569
+
570
+ # What the route says about its body: its own limit, or that it reads
571
+ # the body itself (keepup/body_limit.py).
572
+ declare_limit(wrapper, route)
573
+
574
+ app.add_api_route(
575
+ path,
576
+ wrapper,
577
+ methods=methods,
578
+ include_in_schema=route.get('include_in_schema', True)
579
+ )
580
+
581
+ logger.info(
582
+ f"Registered plugin route: {path} - {methods} (auth={require_auth}, upload={is_upload})")
583
+
584
+
585
+ def _mount_socket(app, declaration):
586
+ """Turn one declared socket into a route on the application.
587
+
588
+ The other half of the same seam: the kernel collects what a plugin declared,
589
+ and this is where HTTP binds it. Kept beside `_mount_route` because one
590
+ declaration should have one way in, whatever collected it.
591
+
592
+ Args:
593
+ app: the FastAPI application.
594
+ declaration: the socket as the plugin declared it.
595
+ """
596
+ ws_route = declaration
597
+ path = ws_route['path']
598
+ handler = ws_route['handler']
599
+ # Off by default, unlike HTTP routes: sockets of agents and guests carry
600
+ # tokens of their own and would be refused by the panel's sign-in rule.
601
+ if ws_route.get('require_auth', False):
602
+ handler = signed_in_websocket(handler)
603
+ _register_websocket(app, path, handler)
604
+ logger.info(f"Registered WebSocket plugin route: {path} "
605
+ f"(auth={bool(ws_route.get('require_auth', False))})")
606
+
607
+
608
+ def _already_mounted(app, path, methods=None) -> bool:
609
+ """Whether the application already answers this address, and these verbs.
610
+
611
+ Asked before a kernel-collected declaration is mounted: the framework's own
612
+ routes and the ones a PluginManager bound are registered first, and a path
613
+ registered twice is an application that answers twice. A socket is asked
614
+ about by path alone -- it has no methods of its own.
615
+
616
+ Args:
617
+ app: the FastAPI application.
618
+ path: the address.
619
+ methods: the verbs, or None for a socket.
620
+
621
+ Returns:
622
+ True when the application already has it.
623
+ """
624
+ for route in getattr(app, "routes", ()):
625
+ if getattr(route, "path", None) != path:
626
+ continue
627
+ existing = getattr(route, "methods", None)
628
+ if methods is None:
629
+ if not existing:
630
+ return True
631
+ continue
632
+ if tuple(sorted(existing or ())) == tuple(sorted(methods)):
633
+ return True
634
+ return False
505
635
 
506
636
 
507
637
  def _register_websocket(app, path, handler):
@@ -276,6 +276,8 @@
276
276
  </div>
277
277
  </div>
278
278
  </div>
279
+ <!-- The second factor, before the shell that uses it (keepup-176). -->
280
+ <script src="/keepup-static/js/mfa_panel.js"></script>
279
281
  <script src="/keepup-static/js/main_new.js"></script>
280
282
  </body>
281
283
 
@@ -269,6 +269,8 @@
269
269
  </div>
270
270
  </div>
271
271
  </div>
272
+ <!-- The second factor, before the shell that uses it (keepup-176). -->
273
+ <script src="/keepup-static/js/mfa_panel.js"></script>
272
274
  <script src="/keepup-static/js/main_new.js"></script>
273
275
  </body>
274
276
 
@@ -77,13 +77,11 @@ const ROLE_CLIENT = 'CLIENT';
77
77
  //
78
78
  // A user holds a set of roles, and the panel decides by the set: somebody who is
79
79
  // both an administrator and a client holds two, and comparing one field with
80
- // ROLE_ADMIN would have made them choose. `role` is the deprecated mirror of the
81
- // set and is still the answer for a user an application assembled itself; it
82
- // goes away in keepup 0.3.0 (keepup-51). Cut out by these markers and executed
83
- // under node -- see tests/user_roles_tests.py.
80
+ // ROLE_ADMIN would have made them choose. The set is the only answer, in the
81
+ // answers and in the table (keepup-51, keepup-168). Cut out by these markers and
82
+ // executed under node -- see tests/user_roles_tests.py.
84
83
  function userRolesOf(user) {
85
- if (user && Array.isArray(user.roles) && user.roles.length) return user.roles;
86
- return user && user.role ? [user.role] : [];
84
+ return user && Array.isArray(user.roles) ? user.roles : [];
87
85
  }
88
86
 
89
87
  function userHasRole(user, role) {
@@ -1189,6 +1187,21 @@ function setupAuthForms() {
1189
1187
  // Updated sign-in form handler
1190
1188
  document.getElementById('loginForm').addEventListener('submit', function (e) {
1191
1189
  e.preventDefault();
1190
+ // The password earned a challenge, not a session: the same form now
1191
+ // carries the code (keepup-176).
1192
+ if (window.MfaPanel && MfaPanel.challengeToken()) {
1193
+ const code = document.getElementById('loginCode');
1194
+ MfaPanel.verify(code ? code.value : '').then(function (result) {
1195
+ if (result.ok) {
1196
+ finishSignIn(result.data);
1197
+ } else {
1198
+ showAuthMessage(result.data.detail || 'That code is not right', 'error');
1199
+ }
1200
+ }).catch(function (error) {
1201
+ showAuthMessage('Network error: ' + error, 'error');
1202
+ });
1203
+ return;
1204
+ }
1192
1205
  const username = document.getElementById('loginUsername').value;
1193
1206
  const password = document.getElementById('loginPassword').value;
1194
1207
  login(username, password);
@@ -1295,26 +1308,20 @@ async function login(username, password) {
1295
1308
  const data = await response.json();
1296
1309
 
1297
1310
  if (response.ok) {
1298
- localStorage.setItem('authToken', SESSION_MARKER);
1299
- scheduleSessionRefresh(data.expires_in);
1300
- const userResponse = await fetch('/api/auth/me');
1301
-
1302
- if (userResponse.ok) {
1303
- const userData = await userResponse.json();
1304
- currentUser = userData;
1305
- updateUIAfterAuth();
1306
- closeAuthModal();
1307
- if (!(await ensureAgreements())) {
1308
- return;
1311
+ // A confirmed second factor answers here with a challenge, not a
1312
+ // session: no marker, no refresh timer, no /me without a cookie
1313
+ // (keepup-176). Before this, the panel called that a success and
1314
+ // then showed "Failed to get user data".
1315
+ if (data.mfa_required) {
1316
+ if (window.MfaPanel) {
1317
+ MfaPanel.showChallenge(data.mfa_token);
1318
+ showAuthMessage('Enter the code from your authenticator app.', 'info');
1319
+ } else {
1320
+ showAuthMessage('This panel cannot ask for the second factor.', 'error');
1309
1321
  }
1310
- await loadModules();
1311
- refreshEntireUI();
1312
-
1313
- closeAuthModal();
1314
- showAuthMessage('Signed in successfully!', 'success');
1315
- } else {
1316
- showAuthMessage('Failed to get user data', 'error');
1322
+ return;
1317
1323
  }
1324
+ await finishSignIn(data);
1318
1325
  } else {
1319
1326
  showAuthMessage(data.detail || 'Sign-in failed', 'error');
1320
1327
  }
@@ -1323,6 +1330,35 @@ async function login(username, password) {
1323
1330
  }
1324
1331
  }
1325
1332
 
1333
+ async function finishSignIn(data) {
1334
+ // The one place a sign-in becomes a session in this browser, whichever
1335
+ // door it came through: password, or password and a code (keepup-176).
1336
+ if (!data || !data.access_token) {
1337
+ showAuthMessage('Sign-in failed: the server answered without a session.', 'error');
1338
+ return;
1339
+ }
1340
+ localStorage.setItem('authToken', SESSION_MARKER);
1341
+ scheduleSessionRefresh(data.expires_in);
1342
+ const userResponse = await fetch('/api/auth/me');
1343
+
1344
+ if (userResponse.ok) {
1345
+ const userData = await userResponse.json();
1346
+ currentUser = userData;
1347
+ updateUIAfterAuth();
1348
+ closeAuthModal();
1349
+ if (!(await ensureAgreements())) {
1350
+ return;
1351
+ }
1352
+ await loadModules();
1353
+ refreshEntireUI();
1354
+
1355
+ closeAuthModal();
1356
+ showAuthMessage('Signed in successfully!', 'success');
1357
+ } else {
1358
+ showAuthMessage('Failed to get user data', 'error');
1359
+ }
1360
+ }
1361
+
1326
1362
  function togglePasswordVisibility(inputId) {
1327
1363
  const passwordInput = document.getElementById(inputId);
1328
1364
  const toggleButton = document.getElementById(inputId + 'Toggle');
@@ -1540,6 +1576,7 @@ async function logout() {
1540
1576
  }
1541
1577
  localStorage.removeItem('authToken');
1542
1578
  localStorage.removeItem('auth_token');
1579
+ if (window.MfaPanel) MfaPanel.clearChallenge();
1543
1580
  currentUser = null;
1544
1581
  headerBadge = null;
1545
1582
  updateUIAfterAuth();
@@ -1812,6 +1849,9 @@ document.head.appendChild(styleSheet);
1812
1849
 
1813
1850
  document.addEventListener('DOMContentLoaded', function() {
1814
1851
  initializeApp();
1852
+ if (window.MfaPanel) {
1853
+ MfaPanel.install();
1854
+ }
1815
1855
  checkAuth();
1816
1856
  });
1817
1857
 
@@ -0,0 +1,342 @@
1
+ // The second factor, seen by a person (keepup-176).
2
+ //
3
+ // The server has answered the API since keepup-174: a confirmed account gets
4
+ // `mfa_required` and a five-minute challenge instead of a session, and the MFA
5
+ // endpoints enrol, confirm and disable the factor. This file is the other half:
6
+ // the code field the sign-in form grows after the password, and the screen that
7
+ // puts a secret into an authenticator app and reads it back.
8
+ //
9
+ // It builds its elements rather than shipping markup in a page: the same script
10
+ // is loaded by every theme, and a theme that replaces the sign-in form still
11
+ // gets the second factor. Text is written with textContent, never spliced into
12
+ // markup.
13
+ (function () {
14
+ 'use strict';
15
+
16
+ const state = {challenge: null, busy: false};
17
+
18
+ function node(tag, className, text) {
19
+ const element = document.createElement(tag);
20
+ if (className) element.className = className;
21
+ if (text !== undefined) element.textContent = text;
22
+ return element;
23
+ }
24
+
25
+ function payloadFor(value) {
26
+ const code = String(value || '').trim();
27
+ if (!code) return null;
28
+ // A recovery code is the only thing people type with a dash. The
29
+ // server accepts both fields and checks the value; this only names the
30
+ // one the person meant.
31
+ return /^[A-Za-z0-9]{4}-[A-Za-z0-9]{4}$/.test(code)
32
+ ? {recovery_code: code}
33
+ : {code: code};
34
+ }
35
+
36
+ async function request(path, body) {
37
+ const answer = await fetch(path, {
38
+ method: body === undefined ? 'GET' : 'POST',
39
+ headers: {'Content-Type': 'application/json'},
40
+ body: body === undefined ? undefined : JSON.stringify(body)
41
+ });
42
+ let data = {};
43
+ try {
44
+ data = await answer.json();
45
+ } catch (error) {
46
+ data = {detail: answer.statusText || 'The server did not answer'};
47
+ }
48
+ return {ok: answer.ok, status: answer.status, data: data};
49
+ }
50
+
51
+ // --- the code field on the sign-in form ---------------------------------
52
+
53
+ function challengeElements() {
54
+ return {
55
+ form: document.getElementById('loginForm'),
56
+ block: document.getElementById('mfaStep'),
57
+ input: document.getElementById('loginCode'),
58
+ username: document.getElementById('loginUsername'),
59
+ password: document.getElementById('loginPassword')
60
+ };
61
+ }
62
+
63
+ function installChallenge() {
64
+ const found = challengeElements();
65
+ if (!found.form || found.block || !found.password) return null;
66
+
67
+ const block = node('div', 'mb-6 hidden');
68
+ block.id = 'mfaStep';
69
+ block.appendChild(node('p', 'text-sm text-gray-600 mb-2',
70
+ 'Enter the six-digit code from your authenticator app.'));
71
+ const input = node('input', 'w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500');
72
+ input.id = 'loginCode';
73
+ input.type = 'text';
74
+ input.inputMode = 'numeric';
75
+ input.autocomplete = 'one-time-code';
76
+ input.placeholder = '000000';
77
+ block.appendChild(input);
78
+ block.appendChild(node('p', 'mt-2 text-xs text-gray-500',
79
+ 'Lost the device? Enter one of your recovery codes instead.'));
80
+ const back = node('button', 'mt-3 text-sm text-blue-600 hover:underline', 'Back to the password');
81
+ back.type = 'button';
82
+ back.addEventListener('click', function () {
83
+ clearChallenge();
84
+ if (found.username) found.username.focus();
85
+ });
86
+ block.appendChild(back);
87
+
88
+ (found.password.closest('.mb-6') || found.password.parentElement)
89
+ .insertAdjacentElement('afterend', block);
90
+ return block;
91
+ }
92
+
93
+ function setSubmitLabel(text) {
94
+ const {form} = challengeElements();
95
+ const submit = form && form.querySelector('button[type="submit"]');
96
+ if (submit) submit.textContent = text;
97
+ }
98
+
99
+ function showChallenge(token) {
100
+ state.challenge = token || null;
101
+ installChallenge();
102
+ const {block, input, username, password} = challengeElements();
103
+ if (username) (username.closest('.mb-4') || username.parentElement).classList.add('hidden');
104
+ if (password) (password.closest('.mb-6') || password.parentElement).classList.add('hidden');
105
+ if (block) block.classList.remove('hidden');
106
+ setSubmitLabel('Verify');
107
+ if (input) {
108
+ input.value = '';
109
+ input.focus();
110
+ }
111
+ }
112
+
113
+ function clearChallenge() {
114
+ state.challenge = null;
115
+ const {block, input, username, password} = challengeElements();
116
+ if (username) (username.closest('.mb-4') || username.parentElement).classList.remove('hidden');
117
+ if (password) (password.closest('.mb-6') || password.parentElement).classList.remove('hidden');
118
+ if (block) block.classList.add('hidden');
119
+ if (input) input.value = '';
120
+ setSubmitLabel('Sign in');
121
+ }
122
+
123
+ async function verify(value) {
124
+ const payload = payloadFor(value);
125
+ if (!state.challenge || !payload) {
126
+ return {ok: false, status: 400, data: {detail: 'Enter a code first'}};
127
+ }
128
+ return request('/api/auth/mfa/verify', {mfa_token: state.challenge, ...payload});
129
+ }
130
+
131
+ // --- the account screen -------------------------------------------------
132
+
133
+ let modal = null;
134
+
135
+ function buildModal() {
136
+ if (modal) return modal;
137
+
138
+ const overlay = node('div', 'modal-overlay hidden');
139
+ overlay.id = 'mfaModal';
140
+ const content = node('div', 'modal-content');
141
+ const header = node('div', 'modal-header');
142
+ header.appendChild(node('h2', 'modal-title', 'Two-factor authentication'));
143
+ const close = node('button', 'close-btn');
144
+ close.type = 'button';
145
+ close.setAttribute('aria-label', 'Close');
146
+ const icon = node('i');
147
+ icon.setAttribute('data-feather', 'x');
148
+ close.appendChild(icon);
149
+ close.addEventListener('click', closeModal);
150
+ header.appendChild(close);
151
+ content.appendChild(header);
152
+
153
+ const body = node('div', 'modal-body');
154
+
155
+ const status = node('p', 'text-sm text-gray-600 mb-4');
156
+ status.id = 'mfaStatus';
157
+ body.appendChild(status);
158
+
159
+ const enabled = node('div', 'hidden');
160
+ enabled.id = 'mfaEnabled';
161
+ enabled.appendChild(node('p', 'text-sm text-gray-600 mb-3',
162
+ 'A code from your authenticator is required when you sign in.'));
163
+ const disableCode = codeInput('mfaDisableCode', 'Code from the app');
164
+ enabled.appendChild(disableCode);
165
+ const disable = node('button', 'mt-3 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700', 'Disable');
166
+ disable.type = 'button';
167
+ disable.addEventListener('click', disableFactor);
168
+ enabled.appendChild(disable);
169
+ body.appendChild(enabled);
170
+
171
+ const disabled = node('div', 'hidden');
172
+ disabled.id = 'mfaDisabled';
173
+ disabled.appendChild(node('p', 'text-sm text-gray-600 mb-3',
174
+ 'Add a second step to your sign-in. You will need an authenticator app.'));
175
+ const start = node('button', 'px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700', 'Set up');
176
+ start.type = 'button';
177
+ start.addEventListener('click', startFactor);
178
+ disabled.appendChild(start);
179
+ body.appendChild(disabled);
180
+
181
+ const setup = node('div', 'hidden');
182
+ setup.id = 'mfaSetup';
183
+ setup.appendChild(node('p', 'text-sm text-gray-600 mb-2',
184
+ 'Type this secret into your authenticator app, or open the address in it.'));
185
+ const secret = node('code', 'block break-all bg-gray-100 rounded p-3 mb-2');
186
+ secret.id = 'mfaSecret';
187
+ setup.appendChild(secret);
188
+ const uri = node('code', 'block break-all text-xs bg-gray-100 rounded p-3 mb-3');
189
+ uri.id = 'mfaUri';
190
+ setup.appendChild(uri);
191
+ setup.appendChild(codeInput('mfaConfirmCode', 'Code from the app'));
192
+ const confirm = node('button', 'mt-3 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700', 'Confirm');
193
+ confirm.type = 'button';
194
+ confirm.addEventListener('click', confirmFactor);
195
+ setup.appendChild(confirm);
196
+ body.appendChild(setup);
197
+
198
+ const recovery = node('div', 'hidden mt-4');
199
+ recovery.id = 'mfaRecovery';
200
+ recovery.appendChild(node('h3', 'font-medium text-gray-800 mb-2', 'Recovery codes'));
201
+ recovery.appendChild(node('p', 'text-sm text-gray-600 mb-2',
202
+ 'Keep these somewhere safe. Each works once, and they will not be shown again.'));
203
+ const list = node('ul', 'font-mono text-sm bg-gray-100 rounded p-3 space-y-1');
204
+ list.id = 'mfaRecoveryList';
205
+ recovery.appendChild(list);
206
+ body.appendChild(recovery);
207
+
208
+ const message = node('p', 'mt-4 text-sm');
209
+ message.id = 'mfaMessage';
210
+ body.appendChild(message);
211
+ content.appendChild(body);
212
+ overlay.appendChild(content);
213
+ overlay.addEventListener('click', function (event) {
214
+ if (event.target === overlay) closeModal();
215
+ });
216
+ document.body.appendChild(overlay);
217
+ modal = overlay;
218
+ return overlay;
219
+ }
220
+
221
+ function codeInput(id, placeholder) {
222
+ const input = node('input', 'w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500');
223
+ input.id = id;
224
+ input.type = 'text';
225
+ input.autocomplete = 'one-time-code';
226
+ input.placeholder = placeholder || '000000';
227
+ return input;
228
+ }
229
+
230
+ function say(text, tone) {
231
+ const message = document.getElementById('mfaMessage');
232
+ if (!message) return;
233
+ message.textContent = text || '';
234
+ message.className = 'mt-4 text-sm ' + (tone === 'error' ? 'text-red-600' : 'text-green-700');
235
+ }
236
+
237
+ function show(id, visible) {
238
+ const element = document.getElementById(id);
239
+ if (element) element.classList.toggle('hidden', !visible);
240
+ }
241
+
242
+ function openModal() {
243
+ buildModal();
244
+ modal.classList.remove('hidden');
245
+ say('', 'ok');
246
+ refreshStatus();
247
+ if (window.feather && window.feather.replace) window.feather.replace();
248
+ }
249
+
250
+ function closeModal() {
251
+ if (modal) modal.classList.add('hidden');
252
+ }
253
+
254
+ async function refreshStatus() {
255
+ const answer = await request('/api/auth/mfa');
256
+ const status = document.getElementById('mfaStatus');
257
+ const enabled = answer.ok && answer.data.enabled;
258
+ if (status) status.textContent = enabled
259
+ ? 'Two-factor authentication is on.'
260
+ : 'Two-factor authentication is off.';
261
+ show('mfaEnabled', enabled);
262
+ show('mfaDisabled', !enabled);
263
+ if (!enabled) show('mfaSetup', false);
264
+ }
265
+
266
+ async function startFactor() {
267
+ const answer = await request('/api/auth/mfa/totp/start', {});
268
+ if (!answer.ok) return say(answer.data.detail || 'Could not start setup', 'error');
269
+ document.getElementById('mfaSecret').textContent = answer.data.secret || '';
270
+ document.getElementById('mfaUri').textContent = answer.data.otpauth_uri || '';
271
+ show('mfaSetup', true);
272
+ show('mfaDisabled', false);
273
+ show('mfaRecovery', false);
274
+ say('Scan the address, then enter the code it shows.', 'ok');
275
+ }
276
+
277
+ async function confirmFactor() {
278
+ const code = document.getElementById('mfaConfirmCode');
279
+ const answer = await request('/api/auth/mfa/totp/confirm', {code: code ? code.value : ''});
280
+ if (!answer.ok) return say(answer.data.detail || 'That code is not right', 'error');
281
+ const list = document.getElementById('mfaRecoveryList');
282
+ list.replaceChildren(...(answer.data.recovery_codes || []).map(value => {
283
+ const item = node('li', '', value);
284
+ return item;
285
+ }));
286
+ show('mfaSetup', false);
287
+ show('mfaRecovery', true);
288
+ show('mfaEnabled', true);
289
+ show('mfaDisabled', false);
290
+ const status = document.getElementById('mfaStatus');
291
+ if (status) status.textContent = 'Two-factor authentication is on.';
292
+ say('Two-factor authentication is on. Save the recovery codes now.', 'ok');
293
+ }
294
+
295
+ async function disableFactor() {
296
+ const code = document.getElementById('mfaDisableCode');
297
+ const answer = await request('/api/auth/mfa/totp/disable', {code: code ? code.value : ''});
298
+ if (!answer.ok) return say(answer.data.detail || 'That code is not right', 'error');
299
+ show('mfaRecovery', false);
300
+ say('Two-factor authentication is off.', 'ok');
301
+ refreshStatus();
302
+ }
303
+
304
+ function installAccountButton() {
305
+ const logout = document.querySelector('[data-action="shell.logout"]');
306
+ if (!logout || document.getElementById('mfaOpen')) return;
307
+ const button = node('button', 'ml-2 p-2 text-gray-600 rounded-full hover:bg-gray-100 hover:text-blue-600 transition-colors duration-150');
308
+ button.id = 'mfaOpen';
309
+ button.type = 'button';
310
+ button.title = 'Two-factor authentication';
311
+ button.setAttribute('aria-label', 'Two-factor authentication');
312
+ const icon = node('i');
313
+ icon.setAttribute('data-feather', 'shield');
314
+ button.appendChild(icon);
315
+ button.addEventListener('click', openModal);
316
+ logout.parentElement.insertBefore(button, logout);
317
+ if (window.feather && window.feather.replace) window.feather.replace();
318
+ }
319
+
320
+ function install() {
321
+ installChallenge();
322
+ installAccountButton();
323
+ document.addEventListener('keydown', function (event) {
324
+ if (event.key === 'Escape' && modal && !modal.classList.contains('hidden')) closeModal();
325
+ });
326
+ }
327
+
328
+ window.MfaPanel = {
329
+ install: install,
330
+ payloadFor: payloadFor,
331
+ verify: verify,
332
+ showChallenge: showChallenge,
333
+ clearChallenge: clearChallenge,
334
+ challengeToken: () => state.challenge,
335
+ open: openModal,
336
+ close: closeModal
337
+ };
338
+
339
+ // The shell calls install() once the page is ready: both pages that load
340
+ // this file also load main_new.js, and a second listener would install the
341
+ // second factor twice (keepup-176).
342
+ })();
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepup-http
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -25,6 +25,7 @@ keepup_http/static/js/aos.js
25
25
  keepup_http/static/js/feather-icons.js
26
26
  keepup_http/static/js/layout_bootstrap.js
27
27
  keepup_http/static/js/main_new.js
28
+ keepup_http/static/js/mfa_panel.js
28
29
  keepup_http/static/js/tailwind.js
29
30
  keepup_http/static/modules/css/background_tasks.css
30
31
  keepup_http/static/modules/css/cluster.css
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "keepup-http"
7
- version = "0.5.0"
7
+ version = "0.6.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