paskia 0.9.1__py3-none-any.whl → 0.10.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. paskia/_version.py +2 -2
  2. paskia/bootstrap.py +8 -7
  3. paskia/db/__init__.py +2 -0
  4. paskia/db/background.py +5 -8
  5. paskia/db/jsonl.py +2 -2
  6. paskia/db/logging.py +130 -45
  7. paskia/db/operations.py +25 -4
  8. paskia/db/structs.py +3 -2
  9. paskia/fastapi/__main__.py +33 -19
  10. paskia/fastapi/admin.py +2 -2
  11. paskia/fastapi/api.py +7 -3
  12. paskia/fastapi/authz.py +11 -9
  13. paskia/fastapi/logging.py +64 -21
  14. paskia/fastapi/mainapp.py +8 -5
  15. paskia/fastapi/remote.py +11 -37
  16. paskia/fastapi/user.py +22 -0
  17. paskia/fastapi/ws.py +12 -35
  18. paskia/fastapi/wschat.py +55 -2
  19. paskia/fastapi/wsutil.py +2 -7
  20. paskia/frontend-build/auth/admin/index.html +7 -6
  21. paskia/frontend-build/auth/assets/{AccessDenied-DPkUS8LZ.css → AccessDenied-CVQZxSIL.css} +1 -1
  22. paskia/frontend-build/auth/assets/AccessDenied-Licr0tqA.js +8 -0
  23. paskia/frontend-build/auth/assets/{RestrictedAuth-CvR33_Z0.css → RestrictedAuth-0MFeNWS2.css} +1 -1
  24. paskia/frontend-build/auth/assets/{RestrictedAuth-DsJXicIw.js → RestrictedAuth-DWKMTEV3.js} +1 -1
  25. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DJsHCwvl.js +33 -0
  26. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DUBf8-iM.css +1 -0
  27. paskia/frontend-build/auth/assets/{admin-DzzjSg72.css → admin-B1H4YqM_.css} +1 -1
  28. paskia/frontend-build/auth/assets/admin-CZKsX1OI.js +1 -0
  29. paskia/frontend-build/auth/assets/{auth-C7k64Wad.css → auth-B4EpDxom.css} +1 -1
  30. paskia/frontend-build/auth/assets/auth-Pe-PKe8b.js +1 -0
  31. paskia/frontend-build/auth/assets/forward-BC0p23CH.js +1 -0
  32. paskia/frontend-build/auth/assets/{pow-2N9bxgAo.js → pow-DUr-T9XX.js} +1 -1
  33. paskia/frontend-build/auth/assets/reset-B8PlNXuP.css +1 -0
  34. paskia/frontend-build/auth/assets/reset-CkY9h28U.js +1 -0
  35. paskia/frontend-build/auth/assets/restricted-C9cJlHkd.js +1 -0
  36. paskia/frontend-build/auth/assets/theme-C2WysaSw.js +1 -0
  37. paskia/frontend-build/auth/index.html +8 -7
  38. paskia/frontend-build/auth/restricted/index.html +7 -6
  39. paskia/frontend-build/int/forward/index.html +6 -6
  40. paskia/frontend-build/int/reset/index.html +4 -4
  41. paskia/frontend-build/paskia.webp +0 -0
  42. paskia/util/__init__.py +0 -0
  43. paskia/util/apistructs.py +110 -0
  44. paskia/util/frontend.py +75 -0
  45. paskia/util/hostutil.py +75 -0
  46. paskia/util/htmlutil.py +47 -0
  47. paskia/util/passphrase.py +20 -0
  48. paskia/util/permutil.py +43 -0
  49. paskia/util/pow.py +45 -0
  50. paskia/util/querysafe.py +11 -0
  51. paskia/util/sessionutil.py +38 -0
  52. paskia/util/startupbox.py +103 -0
  53. paskia/util/timeutil.py +47 -0
  54. paskia/util/useragent.py +10 -0
  55. paskia/util/userinfo.py +63 -0
  56. paskia/util/vitedev.py +71 -0
  57. paskia/util/wordlist.py +54 -0
  58. {paskia-0.9.1.dist-info → paskia-0.10.2.dist-info}/METADATA +14 -11
  59. paskia-0.10.2.dist-info/RECORD +78 -0
  60. paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +0 -8
  61. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +0 -1
  62. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-nhjnO_bd.js +0 -2
  63. paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +0 -1
  64. paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +0 -1
  65. paskia/frontend-build/auth/assets/forward-DmqVHZ7e.js +0 -1
  66. paskia/frontend-build/auth/assets/reset-Chtv69AT.css +0 -1
  67. paskia/frontend-build/auth/assets/reset-s20PATTN.js +0 -1
  68. paskia/frontend-build/auth/assets/restricted-D3AJx3_6.js +0 -1
  69. paskia-0.9.1.dist-info/RECORD +0 -60
  70. {paskia-0.9.1.dist-info → paskia-0.10.2.dist-info}/WHEEL +0 -0
  71. {paskia-0.9.1.dist-info → paskia-0.10.2.dist-info}/entry_points.txt +0 -0
paskia/fastapi/authz.py CHANGED
@@ -2,6 +2,7 @@ import logging
2
2
 
3
3
  from fastapi import HTTPException
4
4
 
5
+ from paskia.fastapi.logging import log_permission_denied
5
6
  from paskia.util import permutil, sessionutil
6
7
 
7
8
  logger = logging.getLogger(__name__)
@@ -79,6 +80,9 @@ async def verify(
79
80
  mode="login",
80
81
  clear_session=True,
81
82
  )
83
+ # User's theme preference for iframe (only if explicitly set)
84
+ user_theme = ctx.user.theme if ctx.user.theme else None
85
+
82
86
  # Check max_age requirement if specified
83
87
  if max_age:
84
88
  try:
@@ -87,29 +91,27 @@ async def verify(
87
91
  status_code=401,
88
92
  detail="Additional authentication required",
89
93
  mode="reauth",
94
+ theme=user_theme,
90
95
  )
91
96
  except ValueError as e:
92
97
  # Invalid max_age format - log but don't fail the request
93
98
  logger.warning(f"Invalid max_age format '{max_age}': {e}")
94
99
 
95
100
  if not match(ctx, perm):
96
- # Determine which permissions are missing for clearer diagnostics
97
101
  effective_scopes = (
98
102
  {p.scope for p in (ctx.permissions or [])}
99
103
  if ctx.permissions
100
104
  else set(ctx.role.permissions or [])
101
105
  )
102
106
  missing = sorted(set(perm) - effective_scopes)
103
- logger.warning(
104
- "Permission denied: user=%s role=%s missing=%s required=%s granted=%s", # noqa: E501
105
- getattr(ctx.user, "uuid", "?"),
106
- getattr(ctx.role, "display_name", "?"),
107
- missing,
108
- perm,
109
- list(effective_scopes),
107
+ log_permission_denied(
108
+ ctx, perm, missing, require_all=(match == permutil.has_all)
110
109
  )
111
110
  raise AuthException(
112
- status_code=403, mode="forbidden", detail="Permission required"
111
+ status_code=403,
112
+ mode="forbidden",
113
+ detail="Permission required",
114
+ theme=user_theme,
113
115
  )
114
116
 
115
117
  return ctx
paskia/fastapi/logging.py CHANGED
@@ -4,8 +4,12 @@ import logging
4
4
  import sys
5
5
  import time
6
6
  from ipaddress import IPv6Address
7
+ from typing import TYPE_CHECKING
7
8
 
8
9
  from starlette.middleware.base import BaseHTTPMiddleware
10
+
11
+ if TYPE_CHECKING:
12
+ from paskia.db.structs import SessionContext
9
13
  from starlette.requests import Request
10
14
  from starlette.responses import Response
11
15
 
@@ -13,18 +17,24 @@ logger = logging.getLogger("paskia.access")
13
17
 
14
18
  _RESET = "\033[0m"
15
19
  _STATUS_INFO = "\033[32m" # 1xx (green)
16
- _STATUS_OK = "\033[92m" # 2xx (bright green)
20
+ _STATUS_OK = "\033[1;92m" # 2xx (bright green)
17
21
  _STATUS_REDIRECT = "\033[32m" # 3xx (green)
18
22
  _STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
19
- _STATUS_SERVER_ERR = "\033[1;31m" # 5xx (bright red)
23
+ _STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
20
24
  _METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
21
- _METHOD_WRITE = "\033[1;34m" # POST, PUT, DELETE, PATCH (bright blue)
22
- _HOST = "\033[1;30m" # hostname (dark grey)
23
- _PATH = "\033[0m" # path (default)
24
- _TIMING = "\033[2m" # timing (dim)
25
- _WS_OPEN = "\033[1;33m" # WebSocket connect (bright yellow)
26
- _WS_CLOSE = "\033[0;33m" # WebSocket disconnect (yellow)
27
- _WS_STATUS = "\033[1;30m" # WebSocket close status (dark grey)
25
+ _METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
26
+ _HOST = "\033[38;5;242m" # hostname (dark grey)
27
+ _PATH = "\033[38;5;250m" # path (white)
28
+ _TIMING = "\033[38;5;242m" # timing/devmode (dark grey)
29
+ _WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
30
+ _WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
31
+ _WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey)
32
+ _AUTHZ_DENIED = "\033[0;31m" # Permission denied (red)
33
+ _AUTHZ_USER = "\033[1;34m" # User info (light blue)
34
+ _AUTHZ_ORG = "\033[34m" # User info (blue)
35
+ _AUTHZ_NEEDS = "\033[1;38;5;231m" # Needs (brightest white)
36
+ _AUTHZ_MISSING = "\033[1;31m" # Missing scope (bold red)
37
+ _AUTHZ_GRANTED = "\033[0;32m" # Granted scope (green)
28
38
 
29
39
 
30
40
  def format_ipv6_network(ip: str) -> str:
@@ -41,8 +51,8 @@ def format_ipv6_network(ip: str) -> str:
41
51
  network_int >>= 16
42
52
  # Compress consecutive zero groups
43
53
  result = ":".join(groups) + "::"
44
- # Simplify leading zeros in groups and compress
45
- return str(IPv6Address(result + "0"))
54
+ # Simplify leading zeros in groups and compress, then strip trailing ::
55
+ return str(IPv6Address(result + "0")).removesuffix("::")
46
56
  except Exception:
47
57
  return ip
48
58
 
@@ -83,7 +93,7 @@ def format_access_log(
83
93
  use_color = sys.stderr.isatty()
84
94
 
85
95
  # Format components with fixed widths for alignment
86
- ip = format_client_ip(client).ljust(15) # IPv4 max 15 chars
96
+ ip = format_client_ip(client).ljust(19) # IPv6 network max 19 chars
87
97
  timing = f"{duration_ms:.0f}ms"
88
98
  method_padded = method.ljust(7) # Longest method is OPTIONS (7)
89
99
 
@@ -116,25 +126,39 @@ def _next_ws_id() -> int:
116
126
  return ws_id
117
127
 
118
128
 
119
- def log_ws_open(client: str, host: str, path: str) -> int:
129
+ def log_ws_open(ws) -> int:
120
130
  """Log WebSocket connection open. Returns connection ID for use in close."""
121
131
  use_color = sys.stderr.isatty()
122
132
  ws_id = _next_ws_id()
123
133
 
124
- ip = format_client_ip(client).ljust(15)
134
+ client = ws.client.host if ws.client else "-"
135
+ host = ws.headers.get("host", "-")
136
+ path = ws.url.path
137
+ origin = ws.headers.get("origin")
138
+
139
+ ip = format_client_ip(client).ljust(19)
125
140
  id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
126
141
 
142
+ # Determine if origin should be shown (omit when same as host)
143
+ # Origin header includes scheme (e.g., "https://example.com"), compare host part
144
+ origin_host = origin.split("://", 1)[-1] if origin else None
145
+ show_origin = origin_host and origin_host != host
146
+
127
147
  if use_color:
128
148
  # 🔌 aligned with status (takes ~2 char width), ID aligned with method
129
149
  prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}"
130
150
  host_str = f"{_HOST}{host}{_RESET}"
131
151
  path_str = f"{_PATH}{path}{_RESET}"
152
+ origin_str = (
153
+ f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
154
+ )
132
155
  else:
133
156
  prefix = f"WS+ {id_str}"
134
157
  host_str = host
135
158
  path_str = path
159
+ origin_str = f" from {origin_host}" if show_origin else ""
136
160
 
137
- logger.info(f"{ip} {prefix} {host_str}{path_str}")
161
+ logger.info(f"{ip} {prefix} {host_str}{path_str}{origin_str}")
138
162
  return ws_id
139
163
 
140
164
 
@@ -158,15 +182,12 @@ WS_CLOSE_CODES = {
158
182
  }
159
183
 
160
184
 
161
- def log_ws_close(
162
- client: str, ws_id: int, close_code: int | None, duration_ms: float
163
- ) -> None:
185
+ def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
164
186
  """Log WebSocket connection close with duration and status."""
165
187
  use_color = sys.stderr.isatty()
166
188
 
167
- ip = format_client_ip(client).ljust(15)
168
189
  id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
169
- timing = f"{duration_ms:.0f}ms"
190
+ timing = f"{duration * 1000:.0f}ms"
170
191
 
171
192
  # Convert close code to status text
172
193
  if close_code is None:
@@ -184,7 +205,27 @@ def log_ws_close(
184
205
  status_str = status
185
206
  timing_str = timing
186
207
 
187
- logger.info(f"{ip} {prefix} {status_str} {timing_str}")
208
+ logger.info(f"{' ' * 19} {prefix} {status_str} {timing_str}")
209
+
210
+
211
+ def log_permission_denied(
212
+ ctx: "SessionContext", required: list[str], missing: list[str], *, require_all: bool
213
+ ) -> None:
214
+ """Log permission denied with org, role, user and highlighted missing scopes."""
215
+ missing_set = set(missing)
216
+ scopes = " ".join(
217
+ f"{_AUTHZ_MISSING}{s}✗{_RESET}"
218
+ if s in missing_set
219
+ else f"{_AUTHZ_GRANTED}{s}✓{_RESET}"
220
+ for s in required
221
+ )
222
+ n = "" if len(required) == 1 else " all" if require_all else " any"
223
+ logger.warning(
224
+ f"{_AUTHZ_DENIED}Permission denied{_RESET} "
225
+ f"{_AUTHZ_USER}{ctx.user.display_name}{_RESET} "
226
+ f"{_AUTHZ_ORG}({ctx.org.display_name} {ctx.role.display_name}){_RESET} "
227
+ f"{_AUTHZ_NEEDS}needs{n}:{_RESET} {scopes}"
228
+ )
188
229
 
189
230
 
190
231
  class AccessLogMiddleware(BaseHTTPMiddleware):
@@ -216,3 +257,5 @@ def configure_access_logging():
216
257
  logger.addHandler(handler)
217
258
  logger.setLevel(logging.INFO)
218
259
  logger.propagate = False
260
+ # Suppress watchfiles "X changes detected" INFO messages (keep WARNING for reload notification)
261
+ logging.getLogger("watchfiles.main").setLevel(logging.WARNING)
paskia/fastapi/mainapp.py CHANGED
@@ -20,10 +20,13 @@ from paskia.util import hostutil, passphrase, vitedev
20
20
  configure_access_logging()
21
21
  configure_db_logging()
22
22
 
23
+ _access_logger = logging.getLogger("paskia.access")
24
+
23
25
  # Vue Frontend static files
24
26
  frontend = Frontend(
25
27
  Path(__file__).parent.parent / "frontend-build",
26
28
  cached=["/auth/assets/"],
29
+ favicon="/paskia.webp",
27
30
  )
28
31
 
29
32
 
@@ -59,7 +62,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
59
62
  if frontend.devmode:
60
63
  logging.getLogger("uvicorn").setLevel(logging.INFO)
61
64
  logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
62
-
63
65
  await frontend.load()
64
66
  await start_background()
65
67
  yield
@@ -134,6 +136,11 @@ async def examples_page():
134
136
  return FileResponse(index_file, media_type="text/html")
135
137
 
136
138
 
139
+ # Frontend static files - must be before /{token} catch-all routes
140
+ # (actual routes registered during lifespan after frontend.load())
141
+ frontend.route(app, "/")
142
+
143
+
137
144
  # Note: this catch-all handler must be the last route defined
138
145
  @app.get("/{token}")
139
146
  @app.get("/auth/{token}")
@@ -146,7 +153,3 @@ async def token_link(token: str):
146
153
  raise HTTPException(status_code=404)
147
154
 
148
155
  return Response(*await vitedev.read("/int/reset/index.html"))
149
-
150
-
151
- # Final catch-all route for frontend files (keep at end of file)
152
- frontend.route(app, "/")
paskia/fastapi/remote.py CHANGED
@@ -17,10 +17,10 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
17
17
 
18
18
  from paskia import db, remoteauth
19
19
  from paskia.authsession import expires
20
- from paskia.fastapi.session import infodict
21
- from paskia.fastapi.wschat import authenticate_chat
20
+ from paskia.fastapi.session import AUTH_COOKIE, infodict
21
+ from paskia.fastapi.wschat import authenticate_and_login
22
22
  from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
23
- from paskia.util import hostutil, passphrase, pow, useragent
23
+ from paskia.util import passphrase, pow, useragent
24
24
 
25
25
  # Create a FastAPI subapp for remote auth WebSocket endpoints
26
26
  app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -252,7 +252,7 @@ async def websocket_remote_auth_request(ws: WebSocket):
252
252
 
253
253
  @app.websocket("/permit")
254
254
  @websocket_error_handler
255
- async def websocket_remote_auth_permit(ws: WebSocket):
255
+ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
256
256
  """Complete a remote authentication request using a 3-word pairing code.
257
257
 
258
258
  This endpoint is called from the user's profile on the authenticating device.
@@ -270,7 +270,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
270
270
  7. Server sends {status: "success", message: "..."}
271
271
  """
272
272
 
273
- origin = validate_origin(ws)
273
+ validate_origin(ws)
274
274
 
275
275
  if remoteauth.instance is None:
276
276
  raise ValueError("Remote authentication is not available")
@@ -310,56 +310,30 @@ async def websocket_remote_auth_permit(ws: WebSocket):
310
310
 
311
311
  # Handle authenticate request (no PoW needed - already validated during lookup)
312
312
  if msg.get("authenticate") and request is not None:
313
- cred, new_sign_count = await authenticate_chat(ws, origin)
314
-
315
- # Create a session for the REQUESTING device
316
- assert cred.uuid is not None
313
+ ctx = await authenticate_and_login(ws, auth)
317
314
 
318
- session_token = None
315
+ session_token = ctx.session.key
319
316
  reset_token = None
320
317
 
321
318
  if request.action == "register":
322
319
  # For registration, create a reset token for device addition
323
-
324
320
  token_str = passphrase.generate()
325
321
  expiry = expires()
326
322
  db.create_reset_token(
327
- user_uuid=cred.user_uuid,
323
+ user_uuid=ctx.user.uuid,
328
324
  passphrase=token_str,
329
325
  expiry=expiry,
330
326
  token_type="device addition",
327
+ user=str(ctx.user.uuid),
331
328
  )
332
329
  reset_token = token_str
333
- # Also create a session so the device is logged in
334
- normalized_host = hostutil.normalize_host(request.host)
335
- session_token = db.login(
336
- user_uuid=cred.user_uuid,
337
- credential_uuid=cred.uuid,
338
- sign_count=new_sign_count,
339
- host=normalized_host,
340
- ip=request.ip,
341
- user_agent=request.user_agent,
342
- expiry=expires(),
343
- )
344
- else:
345
- # Default login action
346
-
347
- normalized_host = hostutil.normalize_host(request.host)
348
- session_token = db.login(
349
- user_uuid=cred.user_uuid,
350
- credential_uuid=cred.uuid,
351
- sign_count=new_sign_count,
352
- host=normalized_host,
353
- ip=request.ip,
354
- user_agent=request.user_agent,
355
- expiry=expires(),
356
- )
357
330
 
358
331
  # Complete the remote auth request (notifies the waiting device)
332
+ cred = db.data().credentials[ctx.session.credential_uuid]
359
333
  completed = await remoteauth.instance.complete_request(
360
334
  token=request.key,
361
335
  session_token=session_token,
362
- user_uuid=cred.user_uuid,
336
+ user_uuid=ctx.user.uuid,
363
337
  credential_uuid=cred.uuid,
364
338
  reset_token=reset_token,
365
339
  )
paskia/fastapi/user.py CHANGED
@@ -57,6 +57,28 @@ async def user_update_display_name(
57
57
  return {"status": "ok"}
58
58
 
59
59
 
60
+ @app.patch("/theme")
61
+ async def user_update_theme(
62
+ request: Request,
63
+ payload: dict = Body(...),
64
+ auth=AUTH_COOKIE,
65
+ ):
66
+ if not auth:
67
+ raise authz.AuthException(
68
+ status_code=401, detail="Authentication Required", mode="login"
69
+ )
70
+ ctx = db.data().session_ctx(auth, request.headers.get("host"))
71
+ if not ctx:
72
+ raise authz.AuthException(
73
+ status_code=401, detail="Session expired", mode="login"
74
+ )
75
+ theme = payload.get("theme", "")
76
+ if theme not in ("", "light", "dark"):
77
+ raise HTTPException(status_code=400, detail="Invalid theme")
78
+ db.update_user_theme(ctx.user.uuid, theme, ctx=ctx)
79
+ return {"status": "ok"}
80
+
81
+
60
82
  @app.post("/logout-all")
61
83
  async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE):
62
84
  if not auth:
paskia/fastapi/ws.py CHANGED
@@ -1,13 +1,13 @@
1
1
  from fastapi import FastAPI, WebSocket
2
2
 
3
3
  from paskia import db
4
- from paskia.authsession import expires, get_reset
4
+ from paskia.authsession import get_reset
5
5
  from paskia.fastapi import authz, remote
6
6
  from paskia.fastapi.session import AUTH_COOKIE, infodict
7
- from paskia.fastapi.wschat import authenticate_chat, register_chat
7
+ from paskia.fastapi.wschat import authenticate_and_login, register_chat
8
8
  from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
9
9
  from paskia.globals import passkey
10
- from paskia.util import hostutil, passphrase
10
+ from paskia.util import passphrase
11
11
 
12
12
  # Create a FastAPI subapp for WebSocket endpoints
13
13
  app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -46,7 +46,7 @@ async def websocket_register_add(
46
46
  s = ctx.session
47
47
 
48
48
  # Get user information and determine effective user_name for this registration
49
- user = db.data().users.get(user_uuid)
49
+ user = db.data().users[user_uuid]
50
50
  user_name = user.display_name
51
51
  if name is not None:
52
52
  stripped = name.strip()
@@ -59,7 +59,7 @@ async def websocket_register_add(
59
59
 
60
60
  # Create a new session and store everything in database
61
61
  metadata = infodict(ws, "authenticated")
62
- token = db.create_credential_session( # type: ignore[attr-defined]
62
+ token = db.create_credential_session(
63
63
  user_uuid=user_uuid,
64
64
  credential=credential,
65
65
  reset_key=(s.key if reset is not None else None),
@@ -89,43 +89,20 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
89
89
 
90
90
  # If there's an existing session, restrict to that user's credentials (reauth)
91
91
  session_user_uuid = None
92
- credential_ids = None
93
92
  if auth:
94
- ctx = db.data().session_ctx(auth, host)
95
- if ctx:
96
- session_user_uuid = ctx.user.uuid
97
- credential_ids = db.get_user_credential_ids(session_user_uuid) or None
93
+ existing_ctx = db.data().session_ctx(auth, host)
94
+ if existing_ctx:
95
+ session_user_uuid = existing_ctx.user.uuid
98
96
 
99
- cred, new_sign_count = await authenticate_chat(ws, origin, credential_ids)
97
+ ctx = await authenticate_and_login(ws, auth)
100
98
 
101
99
  # If reauth mode, verify the credential belongs to the session's user
102
- if session_user_uuid and cred.user_uuid != session_user_uuid:
100
+ if session_user_uuid and ctx.user.uuid != session_user_uuid:
103
101
  raise ValueError("This passkey belongs to a different account")
104
102
 
105
- # Create session and update user/credential in a single transaction
106
- assert cred.uuid is not None
107
- metadata = infodict(ws, "auth")
108
- normalized_host = hostutil.normalize_host(host)
109
- if not normalized_host:
110
- raise ValueError("Host required for session creation")
111
- hostname = normalized_host.split(":")[0]
112
- rp_id = passkey.instance.rp_id
113
- if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
114
- raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
115
-
116
- token = db.login(
117
- user_uuid=cred.user_uuid,
118
- credential_uuid=cred.uuid,
119
- sign_count=new_sign_count,
120
- host=normalized_host,
121
- ip=metadata["ip"],
122
- user_agent=metadata["user_agent"],
123
- expiry=expires(),
124
- )
125
-
126
103
  await ws.send_json(
127
104
  {
128
- "user": str(cred.user_uuid),
129
- "session_token": token,
105
+ "user": str(ctx.user.uuid),
106
+ "session_token": ctx.session.key,
130
107
  }
131
108
  )
paskia/fastapi/wschat.py CHANGED
@@ -7,8 +7,12 @@ from uuid import UUID
7
7
  from fastapi import WebSocket
8
8
 
9
9
  from paskia import db
10
- from paskia.db import Credential
10
+ from paskia.authsession import expires
11
+ from paskia.db import Credential, SessionContext
12
+ from paskia.fastapi.session import infodict
13
+ from paskia.fastapi.wsutil import validate_origin
11
14
  from paskia.globals import passkey
15
+ from paskia.util import hostutil
12
16
 
13
17
 
14
18
  async def register_chat(
@@ -31,7 +35,6 @@ async def register_chat(
31
35
 
32
36
  async def authenticate_chat(
33
37
  ws: WebSocket,
34
- origin: str,
35
38
  credential_ids: list[bytes] | None = None,
36
39
  ) -> tuple[Credential, int]:
37
40
  """Run WebAuthn authentication flow and return the credential and new sign count.
@@ -39,6 +42,7 @@ async def authenticate_chat(
39
42
  Returns:
40
43
  tuple of (credential, new_sign_count) where new_sign_count comes from WebAuthn verification
41
44
  """
45
+ origin = validate_origin(ws)
42
46
  options, challenge = passkey.instance.auth_generate_options(
43
47
  credential_ids=credential_ids
44
48
  )
@@ -60,3 +64,52 @@ async def authenticate_chat(
60
64
 
61
65
  verification = passkey.instance.auth_verify(authcred, challenge, cred, origin)
62
66
  return cred, verification.new_sign_count
67
+
68
+
69
+ async def authenticate_and_login(
70
+ ws: WebSocket,
71
+ auth: str | None = None,
72
+ ) -> SessionContext:
73
+ """Run WebAuthn authentication flow, create session, and return the session context.
74
+
75
+ If auth is provided, restrict authentication to credentials of that session's user.
76
+
77
+ Returns:
78
+ SessionContext for the authenticated session
79
+ """
80
+ origin = validate_origin(ws)
81
+ host = origin.split("://", 1)[1]
82
+ normalized_host = hostutil.normalize_host(host)
83
+ if not normalized_host:
84
+ raise ValueError("Host required for session creation")
85
+ hostname = normalized_host.split(":")[0]
86
+ rp_id = passkey.instance.rp_id
87
+ if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
88
+ raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
89
+ metadata = infodict(ws, "auth")
90
+
91
+ # Get credential IDs if restricting to a user's credentials
92
+ credential_ids = None
93
+ if auth:
94
+ existing_ctx = db.data().session_ctx(auth, host)
95
+ if existing_ctx:
96
+ credential_ids = db.get_user_credential_ids(existing_ctx.user.uuid) or None
97
+
98
+ cred, new_sign_count = await authenticate_chat(ws, credential_ids)
99
+
100
+ # Create session and update user/credential
101
+ token = db.login(
102
+ user_uuid=cred.user_uuid,
103
+ credential_uuid=cred.uuid,
104
+ sign_count=new_sign_count,
105
+ host=normalized_host,
106
+ ip=metadata["ip"],
107
+ user_agent=metadata["user_agent"],
108
+ expiry=expires(),
109
+ )
110
+
111
+ # Fetch and return the full session context
112
+ ctx = db.data().session_ctx(token, normalized_host)
113
+ if not ctx:
114
+ raise ValueError("Failed to create session context")
115
+ return ctx
paskia/fastapi/wsutil.py CHANGED
@@ -21,12 +21,8 @@ def websocket_error_handler(func):
21
21
 
22
22
  @wraps(func)
23
23
  async def wrapper(ws: WebSocket, *args, **kwargs):
24
- client = ws.client.host if ws.client else "-"
25
- host = ws.headers.get("host", "-")
26
- path = ws.url.path
27
-
28
24
  start = time.perf_counter()
29
- ws_id = log_ws_open(client, host, path)
25
+ ws_id = log_ws_open(ws)
30
26
  close_code = None
31
27
 
32
28
  try:
@@ -47,8 +43,7 @@ def websocket_error_handler(func):
47
43
  logging.exception("Internal Server Error")
48
44
  await ws.send_json({"status": 500, "detail": "Internal Server Error"})
49
45
  finally:
50
- duration_ms = (time.perf_counter() - start) * 1000
51
- log_ws_close(client, ws_id, close_code, duration_ms)
46
+ log_ws_close(ws_id, close_code, time.perf_counter() - start)
52
47
 
53
48
  return wrapper
54
49
 
@@ -4,13 +4,14 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Admin</title>
7
- <script type="module" crossorigin src="/auth/assets/admin-CPE1pLMm.js"></script>
8
- <link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-nhjnO_bd.js">
7
+ <script type="module" crossorigin src="/auth/assets/admin-CZKsX1OI.js"></script>
8
+ <link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-DJsHCwvl.js">
9
+ <link rel="modulepreload" crossorigin href="/auth/assets/theme-C2WysaSw.js">
9
10
  <link rel="modulepreload" crossorigin href="/auth/assets/helpers-DzjFIx78.js">
10
- <link rel="modulepreload" crossorigin href="/auth/assets/AccessDenied-Fmeb6EtF.js">
11
- <link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css">
12
- <link rel="stylesheet" crossorigin href="/auth/assets/AccessDenied-DPkUS8LZ.css">
13
- <link rel="stylesheet" crossorigin href="/auth/assets/admin-DzzjSg72.css">
11
+ <link rel="modulepreload" crossorigin href="/auth/assets/AccessDenied-Licr0tqA.js">
12
+ <link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-DUBf8-iM.css">
13
+ <link rel="stylesheet" crossorigin href="/auth/assets/AccessDenied-CVQZxSIL.css">
14
+ <link rel="stylesheet" crossorigin href="/auth/assets/admin-B1H4YqM_.css">
14
15
  </head>
15
16
  <body>
16
17
  <div id="admin-app"></div>
@@ -1 +1 @@
1
- .breadcrumbs[data-v-6344dbb8]{margin:.25rem 0 .5rem;line-height:1.2;color:var(--color-text-muted)}.breadcrumbs ol[data-v-6344dbb8]{list-style:none;padding:0;margin:0;display:flex;flex-wrap:wrap;align-items:center;gap:.25rem}.breadcrumbs li[data-v-6344dbb8]{display:inline-flex;align-items:center;gap:.25rem;font-size:.9rem}.breadcrumbs a[data-v-6344dbb8]{text-decoration:none;color:var(--color-link);padding:0 .25rem;border-radius:4px;transition:color .2s ease,background .2s ease}.breadcrumbs .sep[data-v-6344dbb8]{color:var(--color-text-muted);margin:0}.btn-card-delete{display:none}.credential-item:focus .btn-card-delete{display:block}.user-info.has-extra[data-v-ce373d6c]{grid-template-columns:auto 1fr 2fr;grid-template-areas:"heading heading extra" "org org extra" "label1 value1 extra" "label2 value2 extra" "label3 value3 extra"}.user-info[data-v-ce373d6c]:not(.has-extra){grid-template-columns:auto 1fr;grid-template-areas:"heading heading" "org org" "label1 value1" "label2 value2" "label3 value3"}@media(max-width:720px){.user-info.has-extra[data-v-ce373d6c]{grid-template-columns:auto 1fr;grid-template-areas:"heading heading" "org org" "label1 value1" "label2 value2" "label3 value3" "extra extra"}}.user-name-heading[data-v-ce373d6c]{grid-area:heading;display:flex;align-items:center;flex-wrap:wrap;margin:0 0 .25rem}.org-role-sub[data-v-ce373d6c]{grid-area:org;display:flex;flex-direction:column;margin:-.15rem 0 .25rem}.org-line[data-v-ce373d6c]{font-size:.7rem;font-weight:600;line-height:1.1;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.05em}.role-line[data-v-ce373d6c]{font-size:.65rem;color:var(--color-text-muted);line-height:1.1}.info-label[data-v-ce373d6c]:nth-of-type(1){grid-area:label1}.info-value[data-v-ce373d6c]:nth-of-type(2){grid-area:value1}.info-label[data-v-ce373d6c]:nth-of-type(3){grid-area:label2}.info-value[data-v-ce373d6c]:nth-of-type(4){grid-area:value2}.info-label[data-v-ce373d6c]:nth-of-type(5){grid-area:label3}.info-value[data-v-ce373d6c]:nth-of-type(6){grid-area:value3}.user-info-extra[data-v-ce373d6c]{grid-area:extra;padding-left:2rem;border-left:1px solid var(--color-border)}.user-name-row[data-v-ce373d6c]{display:inline-flex;align-items:center;gap:.35rem;max-width:100%}.user-name-row.editing[data-v-ce373d6c]{flex:1 1 auto}.display-name[data-v-ce373d6c]{font-weight:600;font-size:1.05em;line-height:1.2;max-width:14ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.name-input[data-v-ce373d6c]{width:auto;flex:1 1 140px;min-width:120px;padding:6px 8px;font-size:.9em;border:1px solid var(--color-border-strong);border-radius:6px;background:var(--color-surface);color:var(--color-text)}.user-name-heading .name-input[data-v-ce373d6c]{width:auto}.name-input[data-v-ce373d6c]:focus{outline:none;border-color:var(--color-accent);box-shadow:var(--focus-ring)}.mini-btn[data-v-ce373d6c]{width:auto;padding:4px 6px;margin:0;font-size:.75em;line-height:1;cursor:pointer}.mini-btn[data-v-ce373d6c]:hover:not(:disabled){background:var(--color-accent-soft);color:var(--color-accent)}.mini-btn[data-v-ce373d6c]:active:not(:disabled){transform:translateY(1px)}.mini-btn[data-v-ce373d6c]:disabled{opacity:.5;cursor:not-allowed}@media(max-width:720px){.user-info-extra[data-v-ce373d6c]{padding-left:0;padding-top:1rem;margin-top:1rem;border-left:none;border-top:1px solid var(--color-border)}}dialog[data-v-2ebcbb0a]{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-xl);padding:calc(var(--space-lg) - var(--space-xs));max-width:500px;width:min(500px,90vw);max-height:90vh;overflow-y:auto;position:fixed;inset:0;margin:auto;height:fit-content}dialog[data-v-2ebcbb0a]::backdrop{background:transparent;backdrop-filter:blur(.1rem) brightness(.7);-webkit-backdrop-filter:blur(.1rem) brightness(.7)}dialog[data-v-2ebcbb0a] .modal-title,dialog[data-v-2ebcbb0a] h3{margin:0 0 var(--space-md);font-size:1.25rem;font-weight:600;color:var(--color-heading)}dialog[data-v-2ebcbb0a] form{display:flex;flex-direction:column;gap:var(--space-md)}dialog[data-v-2ebcbb0a] .modal-form{display:flex;flex-direction:column;gap:var(--space-md)}dialog[data-v-2ebcbb0a] .modal-form label{display:flex;flex-direction:column;gap:var(--space-xs);font-weight:500}dialog[data-v-2ebcbb0a] .modal-form input,dialog[data-v-2ebcbb0a] .modal-form textarea{padding:var(--space-md);border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-bg);color:var(--color-text);font-size:1rem;line-height:1.4;min-height:2.5rem}dialog[data-v-2ebcbb0a] .modal-form input:focus,dialog[data-v-2ebcbb0a] .modal-form textarea:focus{outline:none;border-color:var(--color-accent);box-shadow:0 0 0 2px #c7d2fe}dialog[data-v-2ebcbb0a] .modal-actions{display:flex;justify-content:flex-end;gap:var(--space-sm);margin-top:var(--space-md);margin-bottom:var(--space-xs)}.name-edit-form[data-v-b73321cf]{display:flex;flex-direction:column;gap:var(--space-md)}.error[data-v-b73321cf]{color:var(--color-danger-text)}.small[data-v-b73321cf]{font-size:.9rem}.qr-display[data-v-727427c4]{display:flex;flex-direction:column;align-items:center;gap:.75rem}.qr-section[data-v-727427c4]{display:flex;flex-direction:column;align-items:center;gap:.5rem}.qr-link[data-v-727427c4]{display:flex;flex-direction:column;align-items:center;text-decoration:none;color:inherit;border-radius:var(--radius-sm, 6px);overflow:hidden}.qr-code[data-v-727427c4]{display:block;width:200px;height:200px;max-width:100%;object-fit:contain;border-radius:var(--radius-sm, 6px);background:#fff;cursor:pointer}.link-text[data-v-727427c4]{padding:.5rem;font-size:.75rem;color:var(--color-text-muted);font-family:monospace;word-break:break-all;line-height:1.2;transition:color .2s ease}.qr-link:hover .link-text[data-v-727427c4]{color:var(--color-text)}dialog[data-v-e04dd463]{border:none;background:transparent;padding:0;max-width:none;width:fit-content;height:fit-content;position:fixed;inset:0;margin:auto}dialog[data-v-e04dd463]::backdrop{-webkit-backdrop-filter:blur(.2rem) brightness(.5);backdrop-filter:blur(.2rem) brightness(.5)}.icon-btn[data-v-e04dd463]{background:none;border:none;cursor:pointer;font-size:1rem;opacity:.6}.icon-btn[data-v-e04dd463]:hover{opacity:1}.reg-header-row[data-v-e04dd463]{display:flex;justify-content:space-between;align-items:center;gap:.75rem;margin-bottom:.75rem}.reg-title[data-v-e04dd463]{margin:0;font-size:1.25rem;font-weight:600}.device-dialog[data-v-e04dd463]{background:var(--color-surface);padding:1.25rem 1.25rem 1rem;border-radius:var(--radius-md);max-width:480px;width:100%;box-shadow:0 6px 28px #00000040}.reg-help[data-v-e04dd463]{margin:.5rem 0 .75rem;font-size:.85rem;line-height:1.4;text-align:center;color:var(--color-text-muted)}.reg-actions[data-v-e04dd463]{display:flex;justify-content:flex-end;gap:.5rem;margin-top:1rem}.expiry-note[data-v-e04dd463]{font-size:.75rem;color:var(--color-text-muted);text-align:center;margin-top:.75rem}.loading-container[data-v-130f5abf]{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;gap:1rem}.loading-spinner[data-v-130f5abf]{width:40px;height:40px;border:4px solid var(--color-border);border-top:4px solid var(--color-primary);border-radius:50%;animation:spin-130f5abf 1s linear infinite}@keyframes spin-130f5abf{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.loading-container p[data-v-130f5abf]{color:var(--color-text-muted);margin:0}.message-container[data-v-a7b258e7]{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;padding:2rem}.message-content[data-v-a7b258e7]{text-align:center;max-width:480px}.message-content h2[data-v-a7b258e7]{margin:0 0 1rem;color:var(--color-heading)}.message-content .error-detail[data-v-a7b258e7]{margin:0 0 1.5rem;color:var(--color-text-muted)}.message-content .button-row[data-v-a7b258e7]{display:flex;gap:.75rem;justify-content:center}
1
+ .breadcrumbs[data-v-6344dbb8]{margin:.25rem 0 .5rem;line-height:1.2;color:var(--color-text-muted)}.breadcrumbs ol[data-v-6344dbb8]{list-style:none;padding:0;margin:0;display:flex;flex-wrap:wrap;align-items:center;gap:.25rem}.breadcrumbs li[data-v-6344dbb8]{display:inline-flex;align-items:center;gap:.25rem;font-size:.9rem}.breadcrumbs a[data-v-6344dbb8]{text-decoration:none;color:var(--color-link);padding:0 .25rem;border-radius:4px;transition:color .2s ease,background .2s ease}.breadcrumbs .sep[data-v-6344dbb8]{color:var(--color-text-muted);margin:0}.btn-card-delete{display:none}.credential-item:focus .btn-card-delete{display:block}.user-info.has-extra[data-v-ce373d6c]{grid-template-columns:auto 1fr 2fr;grid-template-areas:"heading heading extra" "org org extra" "label1 value1 extra" "label2 value2 extra" "label3 value3 extra"}.user-info[data-v-ce373d6c]:not(.has-extra){grid-template-columns:auto 1fr;grid-template-areas:"heading heading" "org org" "label1 value1" "label2 value2" "label3 value3"}@media(max-width:720px){.user-info.has-extra[data-v-ce373d6c]{grid-template-columns:auto 1fr;grid-template-areas:"heading heading" "org org" "label1 value1" "label2 value2" "label3 value3" "extra extra"}}.user-name-heading[data-v-ce373d6c]{grid-area:heading;display:flex;align-items:center;flex-wrap:wrap;margin:0 0 .25rem}.org-role-sub[data-v-ce373d6c]{grid-area:org;display:flex;flex-direction:column;margin:-.15rem 0 .25rem}.org-line[data-v-ce373d6c]{font-size:.7rem;font-weight:600;line-height:1.1;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.05em}.role-line[data-v-ce373d6c]{font-size:.65rem;color:var(--color-text-muted);line-height:1.1}.info-label[data-v-ce373d6c]:nth-of-type(1){grid-area:label1}.info-value[data-v-ce373d6c]:nth-of-type(2){grid-area:value1}.info-label[data-v-ce373d6c]:nth-of-type(3){grid-area:label2}.info-value[data-v-ce373d6c]:nth-of-type(4){grid-area:value2}.info-label[data-v-ce373d6c]:nth-of-type(5){grid-area:label3}.info-value[data-v-ce373d6c]:nth-of-type(6){grid-area:value3}.user-info-extra[data-v-ce373d6c]{grid-area:extra;padding-left:2rem;border-left:1px solid var(--color-border)}.user-name-row[data-v-ce373d6c]{display:inline-flex;align-items:center;gap:.35rem;max-width:100%}.user-name-row.editing[data-v-ce373d6c]{flex:1 1 auto}.display-name[data-v-ce373d6c]{font-weight:600;font-size:1.05em;line-height:1.2;max-width:14ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.name-input[data-v-ce373d6c]{width:auto;flex:1 1 140px;min-width:120px;padding:6px 8px;font-size:.9em;border:1px solid var(--color-border-strong);border-radius:6px;background:var(--color-surface);color:var(--color-text)}.user-name-heading .name-input[data-v-ce373d6c]{width:auto}.name-input[data-v-ce373d6c]:focus{outline:none;border-color:var(--color-accent);box-shadow:var(--focus-ring)}.mini-btn[data-v-ce373d6c]{width:auto;padding:4px 6px;margin:0;font-size:.75em;line-height:1;cursor:pointer}.mini-btn[data-v-ce373d6c]:hover:not(:disabled){background:var(--color-accent-soft);color:var(--color-accent)}.mini-btn[data-v-ce373d6c]:active:not(:disabled){transform:translateY(1px)}.mini-btn[data-v-ce373d6c]:disabled{opacity:.5;cursor:not-allowed}@media(max-width:720px){.user-info-extra[data-v-ce373d6c]{padding-left:0;padding-top:1rem;margin-top:1rem;border-left:none;border-top:1px solid var(--color-border)}}dialog[data-v-2ebcbb0a]{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-xl);padding:calc(var(--space-lg) - var(--space-xs));max-width:500px;width:min(500px,90vw);max-height:90vh;overflow-y:auto;position:fixed;inset:0;margin:auto;height:fit-content}dialog[data-v-2ebcbb0a]::backdrop{background:transparent;backdrop-filter:blur(.1rem) brightness(.7);-webkit-backdrop-filter:blur(.1rem) brightness(.7)}dialog[data-v-2ebcbb0a] .modal-title,dialog[data-v-2ebcbb0a] h3{margin:0 0 var(--space-md);font-size:1.25rem;font-weight:600;color:var(--color-heading)}dialog[data-v-2ebcbb0a] form{display:flex;flex-direction:column;gap:var(--space-md)}dialog[data-v-2ebcbb0a] .modal-form{display:flex;flex-direction:column;gap:var(--space-md)}dialog[data-v-2ebcbb0a] .modal-form label{display:flex;flex-direction:column;gap:var(--space-xs);font-weight:500}dialog[data-v-2ebcbb0a] .modal-form input,dialog[data-v-2ebcbb0a] .modal-form textarea{padding:var(--space-md);border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-bg);color:var(--color-text);font-size:1rem;line-height:1.4;min-height:2.5rem}dialog[data-v-2ebcbb0a] .modal-form input:focus,dialog[data-v-2ebcbb0a] .modal-form textarea:focus{outline:none;border-color:var(--color-accent);box-shadow:0 0 0 2px #c7d2fe}dialog[data-v-2ebcbb0a] .modal-actions{display:flex;justify-content:flex-end;gap:var(--space-sm);margin-top:var(--space-md);margin-bottom:var(--space-xs)}.name-edit-form[data-v-b73321cf]{display:flex;flex-direction:column;gap:var(--space-md)}.error[data-v-b73321cf]{color:var(--color-danger-text)}.small[data-v-b73321cf]{font-size:.9rem}.qr-display[data-v-727427c4]{display:flex;flex-direction:column;align-items:center;gap:.75rem}.qr-section[data-v-727427c4]{display:flex;flex-direction:column;align-items:center;gap:.5rem}.qr-link[data-v-727427c4]{display:flex;flex-direction:column;align-items:center;text-decoration:none;color:inherit;border-radius:var(--radius-sm, 6px);overflow:hidden}.qr-code[data-v-727427c4]{display:block;width:200px;height:200px;max-width:100%;object-fit:contain;border-radius:var(--radius-sm, 6px);background:#fff;cursor:pointer}.link-text[data-v-727427c4]{padding:.5rem;font-size:.75rem;color:var(--color-text-muted);font-family:monospace;word-break:break-all;line-height:1.2;transition:color .2s ease}.qr-link:hover .link-text[data-v-727427c4]{color:var(--color-text)}dialog[data-v-26360b56]{border:none;background:transparent;padding:0;max-width:none;width:fit-content;height:fit-content;position:fixed;inset:0;margin:auto}dialog[data-v-26360b56]::backdrop{-webkit-backdrop-filter:blur(.2rem) brightness(.5);backdrop-filter:blur(.2rem) brightness(.5)}.icon-btn[data-v-26360b56]{background:none;border:none;cursor:pointer;font-size:1rem;opacity:.6}.icon-btn[data-v-26360b56]:hover{opacity:1}.reg-header-row[data-v-26360b56]{display:flex;justify-content:space-between;align-items:center;gap:.75rem;margin-bottom:.75rem}.reg-title[data-v-26360b56]{margin:0;font-size:1.25rem;font-weight:600}.device-dialog[data-v-26360b56]{background:var(--color-surface);padding:1.25rem 1.25rem 1rem;border-radius:var(--radius-md);max-width:480px;width:100%;box-shadow:0 6px 28px #00000040}.reg-help[data-v-26360b56]{margin:.5rem 0 .75rem;font-size:.85rem;line-height:1.4;text-align:center;color:var(--color-text-muted)}.reg-actions[data-v-26360b56]{display:flex;justify-content:flex-end;gap:.5rem;margin-top:1rem}.expiry-note[data-v-26360b56]{font-size:.75rem;color:var(--color-text-muted);text-align:center;margin-top:.75rem}.loading-container[data-v-130f5abf]{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;gap:1rem}.loading-spinner[data-v-130f5abf]{width:40px;height:40px;border:4px solid var(--color-border);border-top:4px solid var(--color-primary);border-radius:50%;animation:spin-130f5abf 1s linear infinite}@keyframes spin-130f5abf{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.loading-container p[data-v-130f5abf]{color:var(--color-text-muted);margin:0}.message-container[data-v-a7b258e7]{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;padding:2rem}.message-content[data-v-a7b258e7]{text-align:center;max-width:480px}.message-content h2[data-v-a7b258e7]{margin:0 0 1rem;color:var(--color-heading)}.message-content .error-detail[data-v-a7b258e7]{margin:0 0 1.5rem;color:var(--color-text-muted)}.message-content .button-row[data-v-a7b258e7]{display:flex;gap:.75rem;justify-content:center}