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.
- paskia/_version.py +2 -2
- paskia/bootstrap.py +8 -7
- paskia/db/__init__.py +2 -0
- paskia/db/background.py +5 -8
- paskia/db/jsonl.py +2 -2
- paskia/db/logging.py +130 -45
- paskia/db/operations.py +25 -4
- paskia/db/structs.py +3 -2
- paskia/fastapi/__main__.py +33 -19
- paskia/fastapi/admin.py +2 -2
- paskia/fastapi/api.py +7 -3
- paskia/fastapi/authz.py +11 -9
- paskia/fastapi/logging.py +64 -21
- paskia/fastapi/mainapp.py +8 -5
- paskia/fastapi/remote.py +11 -37
- paskia/fastapi/user.py +22 -0
- paskia/fastapi/ws.py +12 -35
- paskia/fastapi/wschat.py +55 -2
- paskia/fastapi/wsutil.py +2 -7
- paskia/frontend-build/auth/admin/index.html +7 -6
- paskia/frontend-build/auth/assets/{AccessDenied-DPkUS8LZ.css → AccessDenied-CVQZxSIL.css} +1 -1
- paskia/frontend-build/auth/assets/AccessDenied-Licr0tqA.js +8 -0
- paskia/frontend-build/auth/assets/{RestrictedAuth-CvR33_Z0.css → RestrictedAuth-0MFeNWS2.css} +1 -1
- paskia/frontend-build/auth/assets/{RestrictedAuth-DsJXicIw.js → RestrictedAuth-DWKMTEV3.js} +1 -1
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DJsHCwvl.js +33 -0
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DUBf8-iM.css +1 -0
- paskia/frontend-build/auth/assets/{admin-DzzjSg72.css → admin-B1H4YqM_.css} +1 -1
- paskia/frontend-build/auth/assets/admin-CZKsX1OI.js +1 -0
- paskia/frontend-build/auth/assets/{auth-C7k64Wad.css → auth-B4EpDxom.css} +1 -1
- paskia/frontend-build/auth/assets/auth-Pe-PKe8b.js +1 -0
- paskia/frontend-build/auth/assets/forward-BC0p23CH.js +1 -0
- paskia/frontend-build/auth/assets/{pow-2N9bxgAo.js → pow-DUr-T9XX.js} +1 -1
- paskia/frontend-build/auth/assets/reset-B8PlNXuP.css +1 -0
- paskia/frontend-build/auth/assets/reset-CkY9h28U.js +1 -0
- paskia/frontend-build/auth/assets/restricted-C9cJlHkd.js +1 -0
- paskia/frontend-build/auth/assets/theme-C2WysaSw.js +1 -0
- paskia/frontend-build/auth/index.html +8 -7
- paskia/frontend-build/auth/restricted/index.html +7 -6
- paskia/frontend-build/int/forward/index.html +6 -6
- paskia/frontend-build/int/reset/index.html +4 -4
- paskia/frontend-build/paskia.webp +0 -0
- paskia/util/__init__.py +0 -0
- paskia/util/apistructs.py +110 -0
- paskia/util/frontend.py +75 -0
- paskia/util/hostutil.py +75 -0
- paskia/util/htmlutil.py +47 -0
- paskia/util/passphrase.py +20 -0
- paskia/util/permutil.py +43 -0
- paskia/util/pow.py +45 -0
- paskia/util/querysafe.py +11 -0
- paskia/util/sessionutil.py +38 -0
- paskia/util/startupbox.py +103 -0
- paskia/util/timeutil.py +47 -0
- paskia/util/useragent.py +10 -0
- paskia/util/userinfo.py +63 -0
- paskia/util/vitedev.py +71 -0
- paskia/util/wordlist.py +54 -0
- {paskia-0.9.1.dist-info → paskia-0.10.2.dist-info}/METADATA +14 -11
- paskia-0.10.2.dist-info/RECORD +78 -0
- paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +0 -8
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +0 -1
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-nhjnO_bd.js +0 -2
- paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +0 -1
- paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +0 -1
- paskia/frontend-build/auth/assets/forward-DmqVHZ7e.js +0 -1
- paskia/frontend-build/auth/assets/reset-Chtv69AT.css +0 -1
- paskia/frontend-build/auth/assets/reset-s20PATTN.js +0 -1
- paskia/frontend-build/auth/assets/restricted-D3AJx3_6.js +0 -1
- paskia-0.9.1.dist-info/RECORD +0 -60
- {paskia-0.9.1.dist-info → paskia-0.10.2.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
104
|
-
|
|
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,
|
|
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;
|
|
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;
|
|
22
|
-
_HOST = "\033[
|
|
23
|
-
_PATH = "\033[
|
|
24
|
-
_TIMING = "\033[
|
|
25
|
-
_WS_OPEN = "\033[1;
|
|
26
|
-
_WS_CLOSE = "\033[
|
|
27
|
-
_WS_STATUS = "\033[
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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"{
|
|
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"{
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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=
|
|
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=
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
95
|
-
if
|
|
96
|
-
session_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
|
-
|
|
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
|
|
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(
|
|
129
|
-
"session_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.
|
|
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(
|
|
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
|
-
|
|
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-
|
|
8
|
-
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-
|
|
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-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/auth/assets/AccessDenied-
|
|
13
|
-
<link rel="stylesheet" crossorigin href="/auth/assets/admin-
|
|
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-
|
|
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}
|