Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2456730f70 | ||
|
|
f74bf3ebe6 | ||
|
|
79074dd4f1 | ||
|
|
00ef0ae2e7 | ||
|
|
6f5287e070 | ||
|
|
051e1bbb41 | ||
|
|
4b156b712c | ||
|
|
df8a7c0026 | ||
|
|
9b28250391 | ||
|
|
b9aec6bb58 | ||
|
|
c79cb497ee | ||
|
|
9f50c8c20d | ||
|
|
816c7a681e | ||
|
|
d31c09084e | ||
|
|
cc938dd306 | ||
|
|
36db1e7e56 | ||
|
|
95c163e37a | ||
|
|
2d0d17c307 | ||
|
|
10980ad39b | ||
|
|
42b54cf645 | ||
|
|
232d0e1ae0 | ||
|
|
e97a2b3291 | ||
|
|
cde709e252 | ||
|
|
72d76df35d | ||
|
|
1a742fc0e7 | ||
|
|
0b29654d6f | ||
|
|
76f24a755b | ||
|
|
5c452f325a | ||
|
|
e9b6bc7a3d | ||
|
|
f5545b48f0 | ||
|
|
c1b2bcf76c | ||
|
|
1806bcab5c | ||
|
|
be177cbafc | ||
|
|
f5ccc204be | ||
|
|
dd2031eef5 | ||
|
|
3cb24bfee9 | ||
|
|
3f51d06f13 | ||
|
|
528a728eb8 | ||
|
|
727625ef4f | ||
|
|
5f7a5ed9b1 | ||
|
|
d64e63527b | ||
|
|
733439b446 | ||
|
|
4e6f63e9ef | ||
|
|
d431c75297 | ||
|
|
6257071efe | ||
|
|
7958b6f365 | ||
|
|
b3cb540098 | ||
|
|
22ba7231b1 | ||
|
|
9a9979fb62 | ||
|
|
9b7855c0af | ||
|
|
dfc4c76d43 | ||
|
|
e1f0fdf664 | ||
|
|
f26ac8f33b | ||
|
|
880ced3b8c | ||
|
|
af80b5eefc | ||
|
|
fa1e69d58b | ||
|
|
39000ef831 | ||
|
|
49119fac81 | ||
|
|
14aae74b60 | ||
|
|
68dccc1378 | ||
|
|
557ffaa0cd | ||
|
|
c0aba07326 | ||
|
|
f830d7d0ec | ||
|
|
7f52276c23 | ||
|
|
c1f8020f6b | ||
|
|
423abb0d1b | ||
|
|
ef7a6c8011 | ||
|
|
97064f7bc7 | ||
|
|
fc0541762e | ||
|
|
cebaa2a757 | ||
|
|
4ebe5ae968 | ||
|
|
a944224027 | ||
|
|
aef0e0cb44 | ||
|
|
d41cffc03e | ||
|
|
bad709a3ab | ||
|
|
1cfde06de9 | ||
|
|
b0b36e88b1 | ||
|
|
2237e6b5e9 | ||
|
|
c2ea01e6d9 | ||
|
|
8b6bdd0f9c | ||
|
|
3ca784dc3c | ||
|
|
d8876d9202 | ||
|
|
aa22b7709f | ||
|
|
a8222f4bba | ||
|
|
16ab111a89 | ||
|
|
d826146932 | ||
|
|
fda9b2545e | ||
|
|
0cf551cb28 | ||
|
|
632230d05c | ||
|
|
a37198bb4b | ||
|
|
fb71ea1220 | ||
|
|
25283eba9b | ||
|
|
6a217978d6 | ||
|
|
4d48c0720a | ||
|
|
083d20c8c9 | ||
|
|
c662fbd39c | ||
|
|
edf80aef52 | ||
|
|
8d5c53ab72 | ||
|
|
f8a760ddfd | ||
|
|
430394a1e5 | ||
|
|
4c54ad5d1f | ||
|
|
0b2be18b44 | ||
|
|
1c8be68811 | ||
|
|
116bc3c4ef | ||
|
|
c641c721c7 | ||
|
|
8fc03ade04 | ||
|
|
6ad3aa7d8c | ||
|
|
d8444b0db8 | ||
|
|
f515daeecd | ||
|
|
5deb57435b | ||
|
|
250189dbe5 | ||
|
|
3469e6fa3f | ||
|
|
c3df6c318c | ||
|
|
615066a2a2 | ||
|
|
70c682b539 | ||
|
|
8444d0399e | ||
|
|
3c5f8694b3 | ||
|
|
871eb149ab | ||
|
|
291a665e21 | ||
|
|
0537b85085 | ||
|
|
af5a48f565 | ||
|
|
632278d4ce | ||
|
|
3faaeee7be | ||
|
|
0f51f8501d | ||
|
|
a7e6eb7341 | ||
|
|
1800dc12ae | ||
|
|
cc55474e62 | ||
|
|
8ac2c8e5fa | ||
|
|
2ee8ddf1d1 | ||
|
|
d58b3742b1 | ||
|
|
5879be39a5 | ||
|
|
5b3406025c | ||
|
|
dda57ac27d | ||
|
|
5e12dcba76 | ||
|
|
be7a9e7f00 | ||
|
|
3d2151fed7 | ||
|
|
58b56a09a4 | ||
|
|
731b36b456 | ||
|
|
8f9cd1124c | ||
|
|
7e49ef296a | ||
|
|
af35ff3d4c | ||
|
|
dac1415a86 | ||
|
|
433844cf08 | ||
|
|
c9ea1c8948 | ||
|
|
58f46c6abf | ||
|
|
7c4418e631 | ||
|
|
1648c8641f | ||
|
|
63eb088dbd | ||
|
|
e88cc004dd | ||
|
|
76921e8b31 | ||
|
|
c1b0aab296 | ||
|
|
8f89bb6d4b | ||
|
|
d16d1ed1c2 | ||
|
|
2cfca81672 | ||
|
|
ce300ebdaf | ||
|
|
7329223784 | ||
|
|
c8d659b5ca | ||
|
|
29ea6426fe | ||
|
|
88a170a37b | ||
|
|
38d240d86d | ||
|
|
53362b8061 | ||
|
|
d3d5f5a3c8 | ||
|
|
d156fb9221 | ||
|
|
f868bc59d1 | ||
|
|
0022986d4e | ||
|
|
b08cca754f | ||
|
|
aa58f08bc5 | ||
|
|
1062b5d6c8 | ||
|
|
3d49cbf2d6 | ||
|
|
13c49aebfd | ||
|
|
abec77d561 | ||
|
|
9b505ff553 | ||
|
|
ddd70e6130 | ||
|
|
cf1124c251 | ||
|
|
7504aaf7e0 | ||
|
|
e8247a2c7f | ||
|
|
968964c4c9 | ||
|
|
6aa1a08e39 | ||
|
|
31f40d874c | ||
|
|
7530d7a710 | ||
|
|
90d5f0e45f | ||
|
|
f0d1b86d6b | ||
|
|
dbdd1dbd3c | ||
|
|
8f862fb4d1 | ||
|
|
cfb917da46 | ||
|
|
3a8e7d1f4f | ||
|
|
86966526c4 | ||
|
|
3196aa7688 | ||
|
|
2fadaea19c | ||
|
|
cb84a81a06 | ||
|
|
9bdca1f43a | ||
|
|
0f29544bdb | ||
|
|
4ddaa9fdf4 | ||
|
|
7e568dbd10 | ||
|
|
fbc6108b7a | ||
|
|
6e649f1f07 | ||
|
|
8d68e5d237 | ||
|
|
5ee7443801 | ||
|
|
2100a7e14f | ||
|
|
aae33e60ce | ||
|
|
cebef8adfc | ||
|
|
57a9c60557 | ||
|
|
a9ef20969e | ||
|
|
57748876cb | ||
|
|
ba552e24cd | ||
|
|
dbe4149b63 | ||
|
|
3d5f82c3df | ||
|
|
2a005692ee | ||
|
|
2ec6314264 | ||
|
|
ae4c982a30 | ||
|
|
c2933d60c2 | ||
|
|
d4ebc1bf99 | ||
|
|
0f857ffb78 | ||
|
|
b7ebe68665 | ||
|
|
f9d23a196c | ||
|
|
2c6a5c72d9 | ||
|
|
c13044c085 | ||
|
|
2c783498a4 | ||
|
|
3430c7f0cf | ||
|
|
236d52aa55 | ||
|
|
02e04da2c4 | ||
|
|
7f3763b46d | ||
|
|
0fe55b2b62 | ||
|
|
ccf71bf0a3 | ||
|
|
cdaeecb179 | ||
|
|
82cdee51e4 | ||
|
|
851e0793a6 | ||
|
|
cd681a0599 | ||
|
|
71cb01cfda | ||
|
|
535ac8558d | ||
|
|
c64554aeda | ||
|
|
0bc1bae26c | ||
|
|
156231b142 | ||
|
|
daf397b3b5 | ||
|
|
a1a5ad8520 | ||
|
|
d25124d30b | ||
|
|
0bfb035f76 | ||
|
|
000501b718 | ||
|
|
5a57e78814 | ||
|
|
e5b84dd28c | ||
|
|
4b01fd9e7a | ||
|
|
431c48f1dd | ||
|
|
03c966919f | ||
|
|
2795b1061f |
@@ -5,6 +5,8 @@ dist/
|
|||||||
*.lock
|
*.lock
|
||||||
package-lock.json
|
package-lock.json
|
||||||
paskia.sqlite
|
paskia.sqlite
|
||||||
|
*.paskiadb
|
||||||
|
*.data
|
||||||
/paskia/frontend-build
|
/paskia/frontend-build
|
||||||
/paskia/_version.py
|
/paskia/_version.py
|
||||||
coverage-html/
|
coverage-html/
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
# Paskia API Documentation
|
|
||||||
|
|
||||||
This document lists the HTTP and WebSocket endpoints exposed by the Paskia
|
|
||||||
service and how they behave depending on whether a dedicated authentication host
|
|
||||||
(`--auth-host` / environment `PASSKEY_AUTH_HOST`) is configured.
|
|
||||||
|
|
||||||
## Base Paths & Host Modes
|
|
||||||
|
|
||||||
Two deployment modes:
|
|
||||||
|
|
||||||
1. Multi‑host (default – no `--auth-host` provided)
|
|
||||||
- All endpoints are reachable on any host under the `/auth/` prefix.
|
|
||||||
- A convenience root (`/`) also serves the main app.
|
|
||||||
|
|
||||||
2. Dedicated auth host (`--auth-host auth.example.com`)
|
|
||||||
- The specified auth host serves the UI at the root (`/`, `/admin/`, reset tokens, etc.).
|
|
||||||
- Other (non‑auth) hosts show a lightweight account summary at `/` or `/auth/`, while other UI routes still redirect to the auth host.
|
|
||||||
- Restricted endpoints on non‑auth hosts return `404` instead of redirecting.
|
|
||||||
|
|
||||||
### Path Mapping When Auth Host Enabled
|
|
||||||
|
|
||||||
| Purpose | On Auth Host | On Other Hosts (incoming) | Action |
|
|
||||||
|---------|--------------|---------------------------|--------|
|
|
||||||
| Main UI | `/` | `/auth/` or `/` | Serve account summary SPA (no redirect) |
|
|
||||||
| Admin UI root | `/admin/` | `/auth/admin/` or `/admin/` | Redirect -> auth host `/admin/` (strip `/auth`) |
|
|
||||||
| Reset / device addition token | `/{token}` | `/auth/{token}` | Redirect -> auth host `/{token}` (strip `/auth`) |
|
|
||||||
| Static assets | `/auth/assets/*` | `/auth/assets/*` | Served directly (no redirect) |
|
|
||||||
| Unrestricted API | `/auth/api/...` | `/auth/api/...` | Served directly |
|
|
||||||
| Restricted API (admin,user,ws namespaces) | `/auth/api/{admin|user|ws}*` | same path | 404 on non‑auth hosts |
|
|
||||||
| WebSocket (register/auth) | `/auth/ws/*` | `/auth/ws/*` | 404 on non‑auth hosts |
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- “Strip `/auth`” means only when the path starts with that exact segment.
|
|
||||||
- A reset token is a single path segment validated by server logic; malformed tokens 404.
|
|
||||||
- Method and body are preserved for UI redirects (307 Temporary Redirect).
|
|
||||||
|
|
||||||
## HTTP UI Endpoints
|
|
||||||
|
|
||||||
| Method | Path (multi‑host) | Path (auth host) | Description |
|
|
||||||
|--------|-------------------|------------------|-------------|
|
|
||||||
| GET | `/auth/` | `/` | Main authentication SPA (non-auth hosts show an account summary view) |
|
|
||||||
| GET | `/auth/admin/` | `/admin/` | Admin SPA root |
|
|
||||||
| GET | `/auth/{reset_token}` | `/{reset_token}` | Reset / device addition SPA (token validated) |
|
|
||||||
|
|
||||||
## Core API (Unrestricted – available on all hosts)
|
|
||||||
|
|
||||||
Always under `/auth/api/` (even on auth host):
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| GET | `/auth/restricted/` | Authentication UI for iframe embedding (supports `?mode=login` or `?mode=reauth`) |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| POST | `/auth/api/validate` | Validate & (conditionally) renew session |
|
|
||||||
| GET | `/auth/api/forward` | Auth proxy endpoint for reverse proxies (204 or 4xx) |
|
|
||||||
| POST | `/auth/api/set-session` | Set cookie from Bearer token |
|
|
||||||
| POST | `/auth/api/logout` | Logout current session |
|
|
||||||
| POST | `/auth/api/user-info` | Authenticated user + context info (also handles reset tokens) |
|
|
||||||
| POST | `/auth/api/create-link` | Create a device addition link (reset token) |
|
|
||||||
| DELETE | `/auth/api/credential/{uuid}` | Delete user credential |
|
|
||||||
| DELETE | `/auth/api/session/{session_id}` | Terminate a specific session |
|
|
||||||
| POST | `/auth/api/user/logout-all` | Terminate all sessions for the user |
|
|
||||||
| PUT | `/auth/api/user/display-name` | Update display name |
|
|
||||||
|
|
||||||
## Restricted API Namespaces
|
|
||||||
|
|
||||||
When `--auth-host` is set, requests to these paths on non‑auth hosts return 404:
|
|
||||||
|
|
||||||
| Namespace | Examples |
|
|
||||||
|-----------|----------|
|
|
||||||
| `/auth/api/admin` | `/auth/api/admin/orgs`, `/auth/api/admin/orgs/{uuid}` ... |
|
|
||||||
| `/auth/api/user` | Segment prefix – includes `/auth/api/user/...` endpoints (logout-all, display-name, session, credential) |
|
|
||||||
| `/auth/api/ws` | (Reserved / future) |
|
|
||||||
|
|
||||||
## WebSockets (Passkey)
|
|
||||||
|
|
||||||
| Path | Description | Host Mode Behavior |
|
|
||||||
|------|-------------|--------------------|
|
|
||||||
| `/auth/ws/register` | Register new credential (new or existing user) | 404 on non‑auth hosts when auth host configured |
|
|
||||||
| `/auth/ws/authenticate` | Authenticate user & issue session | 404 on non‑auth hosts when auth host configured |
|
|
||||||
|
|
||||||
## Redirection & Status Codes
|
|
||||||
|
|
||||||
| Scenario | Response |
|
|
||||||
|----------|----------|
|
|
||||||
| UI path on non‑auth host (auth host configured) | 307 redirect to auth host; `/auth` prefix stripped |
|
|
||||||
| Reset token UI path on non‑auth host | 307 redirect (token preserved) |
|
|
||||||
| Restricted API on non‑auth host | 404 |
|
|
||||||
| Unrestricted API on any host | Normal response |
|
|
||||||
| No auth host configured | All hosts behave like multi-host mode (no redirects; everything accessible) |
|
|
||||||
|
|
||||||
## Headers for /auth/api/forward
|
|
||||||
See `Headers.md` for details of headers returned on success (204).
|
|
||||||
|
|
||||||
## Notes for Integrators
|
|
||||||
1. Always use absolute `/auth/api/...` paths for programmatic requests (they do not move when an auth host is introduced).
|
|
||||||
2. Bookmark / deep links to UI should resolve correctly after redirection if users access via a non-auth application host.
|
|
||||||
3. Treat 404 from restricted namespaces on non-auth hosts as a signal to direct users to the central auth site.
|
|
||||||
|
|
||||||
## Environment & CLI Summary
|
|
||||||
| Option | Effect |
|
|
||||||
|--------|--------|
|
|
||||||
| `--auth-host` / `PASSKEY_AUTH_HOST` | Enables dedicated host mode, root-mounts UI there, restricts certain namespaces elsewhere |
|
|
||||||
|
|
||||||
---
|
|
||||||
This document reflects current behavior of the middleware-based host routing logic.
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
## Caddy configuration
|
|
||||||
|
|
||||||
We provide a few Caddy snippets that make the configuration easier, although the `forward_auth` directive of Caddy can be used directly as well. Place the auth folder with the snippets where your Caddyfile is.
|
|
||||||
|
|
||||||
What these snippets do
|
|
||||||
- Mount the auth UI at `/auth/` proxying to `:4401` (auth backend)
|
|
||||||
- Use the forward-auth interface `/auth/api/forward` to verify the required credentials
|
|
||||||
- Render a login page or a permission denied page if needed (without changing URL)
|
|
||||||
|
|
||||||
Your backend may not use authentication at all, or it can make use of the user information passed via `Remote-*` headers by the authentication system, see [Headers.md](Headers.md) for details.
|
|
||||||
|
|
||||||
### 1) Protect the full site (auth/all)
|
|
||||||
|
|
||||||
Use this when you want “login required everywhere” which is useful to protect some service that doesn't have any authentication of its own:
|
|
||||||
|
|
||||||
```caddyfile
|
|
||||||
localhost {
|
|
||||||
import auth/all "" {
|
|
||||||
reverse_proxy :3000 # your app
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The auth/all protects the entire site with a simple directive. Put your normal setup inside the block. In this example we don't require any permissions, only that the user is logged in. Instead of `""` you may specify `perm=myapp:login` or other permissions.
|
|
||||||
|
|
||||||
It is possible to add your own `handle @matcher` blocks prior importing `auth/all` for endpoints that don't require authentication, e.g. to exclude `/favicon.ico`.
|
|
||||||
|
|
||||||
### 2) Different areas, different permissions (auth/setup, auth/require)
|
|
||||||
|
|
||||||
When you need a more fine-grained control, use the auth/setup and auth/require snippets:
|
|
||||||
|
|
||||||
```caddyfile
|
|
||||||
localhost {
|
|
||||||
import auth/setup
|
|
||||||
|
|
||||||
@public path /.well-known/* /favicon.ico
|
|
||||||
handle @public {
|
|
||||||
root * /var/www/
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
|
|
||||||
@reports path /reports
|
|
||||||
handle @reports {
|
|
||||||
import auth/require perm=myapp:reports
|
|
||||||
reverse_proxy :3000
|
|
||||||
}
|
|
||||||
|
|
||||||
# Anywhere else, require login only
|
|
||||||
handle {
|
|
||||||
import auth/require ""
|
|
||||||
reverse_proxy :3000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: We use the `handle @name` approach rather than `handle_path` to prevent the matched path being removed out of upstream URL. Unlike bare directives, these blocks will be tried in sequence and each can contain what you'd typically put in your site definition.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Override the auth backend URL (AUTH_UPSTREAM)
|
|
||||||
|
|
||||||
By default, the auth service is contacted at localhost port 4401 ("for authentication required"). You can point Caddy to a different by setting the `AUTH_UPSTREAM` environment variable for Caddy.
|
|
||||||
|
|
||||||
If unset, the snippets use `:4401` by default.
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
## Headers your app receives
|
|
||||||
|
|
||||||
When a request is allowed, the auth service adds these headers before proxying to your app (e.g., the service at `:3000`). Your app can use them for user context and authorization.
|
|
||||||
|
|
||||||
| Header | Meaning | Example |
|
|
||||||
|---|---|---|
|
|
||||||
| `Remote-User` | Authenticated user UUID | `3f1a2b3c-4d5e-6789-abcd-ef0123456789` |
|
|
||||||
| `Remote-Name` | User display name | `Jane Doe` |
|
|
||||||
| `Remote-Org` | Organization UUID | `a1b2c3d4-1111-2222-3333-444455556666` |
|
|
||||||
| `Remote-Org-Name` | Organization display name | `Acme Inc` |
|
|
||||||
| `Remote-Role` | Role UUID | `b2c3d4e5-2222-3333-4444-555566667777` |
|
|
||||||
| `Remote-Role-Name` | Role display name | `Administrators` |
|
|
||||||
| `Remote-Groups` | Comma‑separated permissions the user has | `myapp:reports,auth:admin` |
|
|
||||||
| `Remote-Session-Expires` | Session expiry timestamp (ISO 8601) | `2025-09-25T14:30:00Z` |
|
|
||||||
| `Remote-Credential` | Credential UUID backing the session | `c3d4e5f6-3333-4444-5555-666677778888` |
|
|
||||||
|
|
||||||
Note: Any incoming `Remote-*` headers from clients are stripped by our [Caddy configuration](Caddy.md), so that apps can trust these values.
|
|
||||||
@@ -1,72 +1,225 @@
|
|||||||
# Paskia
|
# Paskia
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
|
An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
|
||||||
|
|
||||||
## What is Paskia?
|
## What is Paskia?
|
||||||
|
|
||||||
- Easy to use fully featured auth&auth system (login and permissions)
|
- Easy to use fully featured auth&auth system (login and permissions)
|
||||||
- Organization and role-based access control (optional)
|
- Organization and role-based access control
|
||||||
* Org admins control their users and roles
|
* Org admins control their users and roles
|
||||||
* Master admin can create multiple independent orgs
|
* Multiple independent orgs
|
||||||
* Master admin makes permissions available for orgs to assign
|
* Master admin can do everything or delegate to org admins
|
||||||
- User Profile and Administration by API and web interface.
|
- User Profile and Admin by API and web interface
|
||||||
under `/auth/` or `auth.example.com`
|
- Implements login/reauth/forbidden flows for you
|
||||||
- Reset tokens and additional device linking via QR code or codewords.
|
- Single Sign-On (SSO): Users register once and authenticate across your services
|
||||||
- Pure Python, FastAPI, packaged with prebuilt Vue frontend
|
- Remote autentication by entering random keywords from another device (like 2fa)
|
||||||
|
- No CORS, NodeJS or anything extra needed.
|
||||||
|
|
||||||
|
## Authenticate to get to your app, or in your app
|
||||||
|
|
||||||
Two interfaces:
|
|
||||||
- API fetch: auth checks and login without leaving your app
|
- API fetch: auth checks and login without leaving your app
|
||||||
- Forward-auth proxy: protect any unprotected site or service (Caddy, Nginx)
|
- Forward-auth proxy: protect any unprotected site or service ([Caddy](docs/proxy/caddy.md), [Nginx](docs/proxy/nginx.md), and [others](docs/proxy/index.md))
|
||||||
|
|
||||||
The API mode is useful for applications that can be customized to run with Paskia. Forward auth can also protect your javascript and other assets. Each provides fine-grained permission control and reauthentication requests where needed, and both can be mixed where needed.
|
The API mode is useful for applications that can be customized to run with Paskia. Forward auth can also protect your javascript and other assets. Each provides fine-grained permission control and reauthentication requests where needed, and both can be mixed where needed.
|
||||||
|
|
||||||
Single Sign-On (SSO): Users register once and authenticate across all applications under your domain name (configured rp-id).
|
## Authentication flows already done
|
||||||
|
|
||||||
|

|
||||||
|
**Automatic light/dark mode switching with overrides by user profile and protected app's theme.**
|
||||||
|
|
||||||
|
Paskia includes set of login, reauthentication and forbidden dialogs that it can use to perform the needed flows. We never leave the URL, no redirections, and if you make use of API mode, we won't even interrupt whatever your app was doing but retry the blocked API fetch after login like nothing happened.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
|
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
|
||||||
|
|
||||||
```fish
|
```sh
|
||||||
uvx paskia serve --rp-id example.com
|
uvx paskia --rp-id example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
On the first run it downloads the software and prints a registration link for the Admin. If you are going to be connecting `localhost` directly, for testing, leave out the rp-id.
|
On the first run it downloads the software and prints a registration link for the Admin. The server starts on [localhost:4401](http://localhost:4401), serving authentication for `*.example.com`. For local testing, leave out `--rp-id`.
|
||||||
|
|
||||||
The server will start up on [localhost:4401](http://localhost:4401) "for authentication required", serving for `*.example.com`.
|
For production you need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps (see documentation below).
|
||||||
|
|
||||||
Otherwise you will need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps.
|
|
||||||
|
|
||||||
A quick example without any config file:
|
|
||||||
```fish
|
|
||||||
sudo caddy reverse-proxy --from example.com --to :4401
|
|
||||||
```
|
|
||||||
|
|
||||||
For a permanent install of `paskia` CLI command, not needing `uvx`:
|
For a permanent install of `paskia` CLI command, not needing `uvx`:
|
||||||
|
|
||||||
```fish
|
```sh
|
||||||
uv tool install paskia
|
uv tool install paskia
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
There is no config file. Pass only the options on CLI:
|
You will need to specify your main domain to which all passkeys will be tied as rp-id. Use your main domain even if Paskia is not running there. All other options are optional.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
paskia serve [options]
|
paskia [options]
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional options:
|
| Option | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** |
|
||||||
|
| --rp-id *domain* | Main/top domain for passkeys | **localhost** |
|
||||||
|
| --rp-name *"text"* | Branding name for the entire system (passkey auth, login dialog). | Same as rp-id |
|
||||||
|
| --origin *url* | Only sites listed can login (repeatable) | rp-id and all subdomains |
|
||||||
|
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
|
||||||
|
| --save | Save current options to database | (only --rp-id required on further invocations) |
|
||||||
|
|
||||||
- Listen address (one of):
|
To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` folder in current directory. This can be overridden by environment `PASKIA_DB` if needed.
|
||||||
* `[host]:port`: Address and port (default: `localhost:4401`)
|
|
||||||
* `unix:/path.sock`: Unix socket
|
|
||||||
- `--rp-id <domain>`: Main domain (required for production)
|
|
||||||
- `--rp-name "<text>"`: Name of your company or site (default: same as rp-id)
|
|
||||||
- `--origin <url>`: Explicit single site (default: `https://<rp-id>`)
|
|
||||||
- `--auth-host <domain>`: Dedicated authentication site (e.g., `auth.example.com`)
|
|
||||||
|
|
||||||
## Documentation
|
## Tutorial: From Local Testing to Production
|
||||||
|
|
||||||
- `API.md`: Complete HTTP and WebSocket API reference
|
This section walks you through a complete example, from running Paskia locally to protecting a real site in production.
|
||||||
- `Caddy.md`: Caddy configuration examples
|
|
||||||
- `Headers.md`: HTTP headers passed to protected applications
|
### Step 1: Production Configuration
|
||||||
|
|
||||||
|
For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uvx paskia --rp-id=example.com --rp-name="Example Corp"
|
||||||
|
```
|
||||||
|
|
||||||
|
This binds passkeys to the rp-id, allowing them to be used there or on any subdomain of it. The `--rp-name` is the branding shown in UI and registered with passkeys for everything on your domain (rp id). On the first run, you'll see a registration link—use it to create your Admin account. You may enter your real name here for a more suitable account name.
|
||||||
|
|
||||||
|
### Step 2: Set Up Caddy
|
||||||
|
|
||||||
|
Install [Caddy](https://caddyserver.com/) and copy the [auth folder](caddy/auth) to `/etc/caddy/auth`. Say your current unprotected Caddyfile looks like this:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
app.example.com {
|
||||||
|
reverse_proxy :3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add Paskia full site protection:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
app.example.com {
|
||||||
|
import auth/setup
|
||||||
|
handle {
|
||||||
|
import auth/require perm=myapp:login
|
||||||
|
reverse_proxy :3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `systemctl reload caddy`. Now `app.example.com` requires the `myapp:login` permission. Try accessing it and you'll land on a login dialog.
|
||||||
|
|
||||||
|
### Step 3: Assign Permissions via Admin Panel
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
1. Go to `app.example.com/auth/admin/`
|
||||||
|
2. Create a permission, give it a name and scope `myapp:login`
|
||||||
|
3. Assign it to Organization
|
||||||
|
4. In that organization, assign it to the Administration role
|
||||||
|
|
||||||
|
Now you have granted yourself the new permission.
|
||||||
|
|
||||||
|
Permission scopes are text identifiers with colons as separators that we can use for permission checks. The `myapp:` prefix is a convention to namespace permissions per application—you but you can use other forms as you see fit (urlsafe characters, no spaces allowed).
|
||||||
|
|
||||||
|
### Step 4: Add API Authentication to Your App
|
||||||
|
|
||||||
|
Your backend already receives `Remote-*` headers from Caddy's forward-auth. For frontend API calls, we provide a [JS paskia module](https://www.npmjs.com/package/paskia):
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { apiJson } from 'https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js'
|
||||||
|
|
||||||
|
const data = await apiJson('/api/sensitive', { method: 'POST' })
|
||||||
|
```
|
||||||
|
|
||||||
|
When a 401/403 occurs, the auth dialog appears automatically, and the request retries after authentication.
|
||||||
|
|
||||||
|
To protect the API path with a different permission, update your Caddyfile:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
app.example.com {
|
||||||
|
import auth/setup
|
||||||
|
|
||||||
|
@api path /api/*
|
||||||
|
handle @api {
|
||||||
|
import auth/require perm=myapp:api
|
||||||
|
reverse_proxy :3000
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
import auth/require perm=myapp:login
|
||||||
|
reverse_proxy :3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the `myapp:api` permission in the admin panel, that will be required for all API access. Link to `/auth/` for the built-in profile page.
|
||||||
|
|
||||||
|
You may also remove the `myapp:login` protection from the rest of your site paths, unless you wish to keep all your assets behind a login page. Having this as the last entry in your config allows free access to everything not matched by other sections.
|
||||||
|
|
||||||
|
```Caddyfile
|
||||||
|
handle {
|
||||||
|
reverse_proxy :3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Run Paskia as a Service
|
||||||
|
|
||||||
|
Create a system user paskia, install UV on the system, and create a systemd unit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo useradd --system --home-dir /srv/paskia --create-home paskia
|
||||||
|
```
|
||||||
|
|
||||||
|
Install UV on the system (or arch btw `pacman -S uv`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a systemd unit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl edit --force --full paskia@.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste the following and save:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Paskia for %i
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=paskia
|
||||||
|
WorkingDirectory=/srv/paskia
|
||||||
|
ExecStart=uvx paskia@latest --rp-id=%i
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the service and view log:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl enable --now paskia@example.com && sudo journalctl -n30 -ocat -fu paskia@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional: Dedicated Authentication Site
|
||||||
|
|
||||||
|
Add a Caddy configuration for the authentication domain:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
auth.example.com {
|
||||||
|
reverse_proxy :4401
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Now all authentication happens at `auth.example.com` instead of `/auth/` paths on your apps. Your existing protected sites continue to work as before but they just forward to the dedicated site for user profile and other such functionality.
|
||||||
|
|
||||||
|
Enter your auth site domain on Admin / Server Options panel or use `--auth-host=auth.example.com` when starting the server.
|
||||||
|
|
||||||
|
|
||||||
|
## Further Documentation
|
||||||
|
|
||||||
|
- [Forward-Auth Guides](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/proxy/index.md) (Caddy, Nginx, ...)
|
||||||
|
- [Trusted Headers for Backend Apps](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/Headers.md)
|
||||||
|
- [Frontend integration](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/Integration.md)
|
||||||
|
- [Paskia API](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/API.md)
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
# Paskia API
|
||||||
|
|
||||||
|
[Integration](Integration.md) · [Proxy guides](proxy/index.md)
|
||||||
|
|
||||||
|
For integrating Paskia with your app frontend, see [integration](Integration.md).
|
||||||
|
|
||||||
|
## Web Interface
|
||||||
|
|
||||||
|
| Method | Path | What it is for | Responses |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| GET | /auth/ | User profile page | HTML 200/401 |
|
||||||
|
| GET | /auth/admin/ | Admin panel, requires auth:admin or org admin permissions | HTML 200/401/403 |
|
||||||
|
| GET | /auth/{token} | Reset / add credential URL (QR code link), e.g. /auth/fun.cotton.fresh.xray.lava | HTML 200 |
|
||||||
|
|
||||||
|
### Public JSON API: /auth/api/*
|
||||||
|
|
||||||
|
| Method | Path | Used for | Responses |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| GET | /auth/api/settings | Paskia configuration: RP info, base paths, session cookie name | 200 |
|
||||||
|
| GET | /auth/api/user-info | Full user profile: info, credentials, sessions, permissions | 200/401 |
|
||||||
|
| POST | /auth/api/logout | Terminate session and delete session cookie on the current host | 200 |
|
||||||
|
| POST | [/auth/api/validate](api/validate.md) | Validate and renew the session cookie; query [perm](api/perm.md), [max_age](api/max-age.md) | 200/401/403 |
|
||||||
|
| GET | [/auth/api/forward](api/forward.md) | Forward-auth with reverse proxies; see [proxy guides](proxy/index.md), query [perm](api/perm.md), [max_age](api/max-age.md) | 204/401/403 empty, json or html|
|
||||||
|
|
||||||
|
### User JSON API: /auth/api/user/*
|
||||||
|
|
||||||
|
| Method | Path | Used for | Responses |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| PATCH | /auth/api/user/display-name | Update the user's display name | 200/401 |
|
||||||
|
| POST | /auth/api/user/logout-all | Terminate all user sessions | 200/401 |
|
||||||
|
| DELETE | /auth/api/user/session/{session_id} | Terminate one session | 200/401 |
|
||||||
|
| DELETE | /auth/api/user/credential/{uuid} | Delete a credential; requires recent authentication | 200/401/403 |
|
||||||
|
| POST | /auth/api/user/create-link | Create a device-add link; requires recent authentication | 200/401/403 |
|
||||||
|
| GET | /auth/api/user/{uuid}/profile.webp | Canonical avatar image URL, public on the auth host | 200/304/404 |
|
||||||
|
| PUT | /auth/api/user/{uuid}/profile.webp | Upload or replace an avatar; square WebP prepared in the browser | 200/401/403 |
|
||||||
|
| DELETE | /auth/api/user/{uuid}/profile.webp | Remove an avatar; allowed for the user or an admin | 200/401/403 |
|
||||||
|
|
||||||
|
These are used mostly from the user profile panel by the user himself, but the profile pictures are public for all to read.
|
||||||
|
|
||||||
|
### Admin API: /auth/api/admin/*
|
||||||
|
|
||||||
|
Normally only used via admin panel, requires auth admin permissions and can modify any users, orgs and permissions the session has access to.
|
||||||
|
|
||||||
|
E.g. Org admin cannot see anything of the other orgs that he has no admin access to. Master admin auth:admin can see everything and create and manage orgs.
|
||||||
|
|
||||||
|
| Method | Path | Used for | Responses |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| GET | /auth/api/admin/info | Admin overview: orgs, permissions, OIDC clients | 200/401/403 |
|
||||||
|
| POST | /auth/api/admin/permissions/ | Create permission | 200/401/403 |
|
||||||
|
| PATCH | /auth/api/admin/permissions/{uuid} | Update permission | 200/401/403 |
|
||||||
|
| DELETE | /auth/api/admin/permissions/{uuid} | Delete permission | 200/401/403 |
|
||||||
|
| POST | /auth/api/admin/orgs/ | Create organization | 200/401/403 |
|
||||||
|
| GET | /auth/api/admin/orgs/{uuid} | Get organization details | 200/401/403 |
|
||||||
|
| PATCH | /auth/api/admin/orgs/{uuid} | Update organization | 200/401/403 |
|
||||||
|
| DELETE | /auth/api/admin/orgs/{uuid} | Delete organization | 200/401/403 |
|
||||||
|
| POST | /auth/api/admin/orgs/{uuid}/users | Create user in org | 200/401/403 |
|
||||||
|
| POST | /auth/api/admin/orgs/{uuid}/roles | Create role in org | 200/401/403 |
|
||||||
|
| POST | /auth/api/admin/orgs/{uuid}/permission | Grant permission to org | 200/401/403 |
|
||||||
|
| DELETE | /auth/api/admin/orgs/{uuid}/permission | Revoke permission from org | 200/401/403 |
|
||||||
|
| PATCH | /auth/api/admin/roles/{uuid} | Update role | 200/401/403 |
|
||||||
|
| POST | /auth/api/admin/roles/{uuid}/permissions/{uuid} | Add permission to role | 200/401/403 |
|
||||||
|
| DELETE | /auth/api/admin/roles/{uuid}/permissions/{uuid} | Remove permission from role | 200/401/403 |
|
||||||
|
| DELETE | /auth/api/admin/roles/{uuid} | Delete role | 200/401/403 |
|
||||||
|
| PATCH | /auth/api/admin/users/{uuid}/role | Update user role | 200/401/403 |
|
||||||
|
| PATCH | /auth/api/admin/users/{uuid}/info | Update user info | 200/401/403 |
|
||||||
|
| GET | /auth/api/admin/users/{uuid} | Get user details | 200/401/403 |
|
||||||
|
| DELETE | /auth/api/admin/users/{uuid} | Delete user | 200/401/403 |
|
||||||
|
| POST | /auth/api/admin/users/{uuid}/create-link | Create device add link | 200/401/403 |
|
||||||
|
| DELETE | /auth/api/admin/users/{uuid}/credentials/{uuid} | Delete user credential | 200/401/403 |
|
||||||
|
| DELETE | /auth/api/admin/users/{uuid}/sessions/{key} | Delete user session | 200/401/403 |
|
||||||
|
| POST | /auth/api/admin/oidc-clients/ | Create OIDC client | 200/401/403 |
|
||||||
|
| PATCH | /auth/api/admin/oidc-clients/{uuid} | Update OIDC client | 200/401/403 |
|
||||||
|
| PATCH | /auth/api/admin/oidc-clients/{uuid}/reset-secret | Reset client secret | 200/401/403 |
|
||||||
|
| DELETE | /auth/api/admin/oidc-clients/{uuid} | Delete OIDC client | 200/401/403 |
|
||||||
|
| GET | /auth/api/admin/server-config/ | Get server config | 200/401/403 |
|
||||||
|
| PATCH | /auth/api/admin/server-config/ | Update server config | 200/401/403 |
|
||||||
|
|
||||||
|
### WebSockets: /auth/ws/*
|
||||||
|
|
||||||
|
| Path | Used for | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| WS /auth/ws/authenticate | Passkey authentication | Returns a session token |
|
||||||
|
| WS /auth/ws/register | Register a new credential | Adding another passkey to current user or via reset token |
|
||||||
|
| WS /auth/ws/remote-auth/request | Start a cross-device login/registration request | Used from unauthenticated client |
|
||||||
|
| WS /auth/ws/remote-auth/permit | Approve/deny a pairing code | Used to accept the request, if same words are entered |
|
||||||
|
|
||||||
|
These are for internal use only, but are documented here because they are the core piece in all passkey operations.
|
||||||
|
|
||||||
|
### Auth host mode (--auth-host)
|
||||||
|
|
||||||
|
#### On the auth host:
|
||||||
|
- The Web UI is served at site root instead of /auth/* (that redirects to root paths)
|
||||||
|
- All of the API stays under /auth/api/*
|
||||||
|
- Auth WebSockets remain at /auth/ws/* but take connections from other hosts to issue sessions for each of those.
|
||||||
|
|
||||||
|
#### On non-auth hosts:
|
||||||
|
- /auth/ shows only minimal profile and allows logging out of the current site, link to full profile on auth host
|
||||||
|
- /auth/api/* is served normally.
|
||||||
|
- /auth/api/user/*, /auth/api/admin/*, and /auth/ws/* don't exist.
|
||||||
|
|
||||||
|
The WebSocket connections are directed to auth host, and must have an allowed origin corresponding to the host where the user is logging in, that the session is tied with.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Paskia Trusted Headers for Backend Apps
|
||||||
|
|
||||||
|
[Proxy guides](proxy/index.md) · [`/auth/api/forward`](api/forward.md)
|
||||||
|
|
||||||
|
| HTTP Header | Meaning | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| Remote-User | Authenticated user UUID | **01c03276-b8f0-**… (string) |
|
||||||
|
| Remote-Name | User display name | **John Doe** |
|
||||||
|
| Remote-Org | Organization UUID | Identifier for user's org (string) |
|
||||||
|
| Remote-Org-Name | Organization display name | **The Company Ltd.** |
|
||||||
|
| Remote-Role | Role UUID | Identifier for user's role (string) |
|
||||||
|
| Remote-Role-Name | Role display name | **Employee** |
|
||||||
|
| Remote-Groups | Permissions the user has, comma separated | **auth:admin,yourapp:reports** |
|
||||||
|
| Remote-Session-Expires | Session expiry timestamp (ISO 8601 UTC) | **2030-12-31T23:59:59Z** |
|
||||||
|
| Remote-Credential | Credential UUID | Identifier for the sign-in passkey (string) |
|
||||||
|
|
||||||
|
Similar headers are also used by other authentication systems like [Authelia](https://www.authelia.com/integration/trusted-header-sso/introduction/) to signal the backend application information about the signed in user.
|
||||||
|
|
||||||
|
When a request is allowed, the auth service adds these headers by the forward-auth mechanism before proxying to your app as **request headers**. Your app can use them for user context to show on UI, or for its own authentication needs (e.g. prevent different orgs messing up with each other's data, logging which user performed an action).
|
||||||
|
|
||||||
|
Only the UUID values should be used for identification needs, because they never change, even when things are renamed (display names change), and are never reused (created on authentication server). They are UUIDv7 so you can also extract the creation timestamp from them.
|
||||||
|
|
||||||
|
Any `Remote-*` headers from clients are stripped by the proxy configuration (see our [Caddy configuration](proxy/caddy.md) and [Forward-Auth Proxy Guides](proxy/index.md)) to avoid dealing with any fake headers.
|
||||||
|
|
||||||
|
Note: the headers are intended primarily for the backend, while either frontend or backend (passing the session cookie) can request `/auth/api/user-info` for more complete information, and that is the recommended way to do it in the frontend. See [integration](Integration.md) for more.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# Integrating Paskia with your App
|
||||||
|
|
||||||
|
[API overview](API.md) · [Proxy guides](proxy/index.md)
|
||||||
|
|
||||||
|
This guide covers frontend and backend integration with Paskia. For forward-auth setup, see the [Forward-Auth Proxy Guides](proxy/index.md); Caddy users can also start from the dedicated [Caddy configuration](proxy/caddy.md).
|
||||||
|
|
||||||
|
## Frontend Integration
|
||||||
|
|
||||||
|
### Using the paskia-js Module
|
||||||
|
|
||||||
|
The [paskia](https://www.npmjs.com/package/paskia) JavaScript module provides utilities for API calls, session validation, and authentication overlays. Works with any framework or vanilla JS.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script type="module">
|
||||||
|
import { apiJson, apiFetch, SessionValidator } from 'https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js'
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install to your project:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install paskia
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Fetch with Automatic Auth
|
||||||
|
|
||||||
|
Use `apiJson` or `apiFetch` for API calls. When a 401/403 response includes an auth URL, the authentication dialog appears automatically, then the request retries. The JSON variant is purely for convenience, doing JSON headers and conversions for you.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { apiJson, apiFetch, AuthCancelledError } from 'paskia'
|
||||||
|
|
||||||
|
// JSON API call (sets Content-Type, parses response)
|
||||||
|
try {
|
||||||
|
const data = await apiJson('/api/endpoint', { method: 'POST', body: { key: 'value' } })
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AuthCancelledError) {
|
||||||
|
// User cancelled auth dialog
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw fetch with auth handling (returns Response object)
|
||||||
|
const response = await apiFetch('/api/endpoint')
|
||||||
|
```
|
||||||
|
|
||||||
|
For requests that shouldn't trigger auth dialogs, use standard `fetch` or our `fetchJson`.
|
||||||
|
|
||||||
|
### Session Validation Polling
|
||||||
|
|
||||||
|
Keep sessions alive and detect when the user logs out or switches accounts:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { SessionValidator } from 'paskia'
|
||||||
|
|
||||||
|
const validator = new SessionValidator(
|
||||||
|
() => currentUser?.uuid, // getter for current user ID
|
||||||
|
(error) => handleSessionLost(error) // callback when session is lost or user changes
|
||||||
|
)
|
||||||
|
|
||||||
|
validator.start() // start polling (pauses on idle)
|
||||||
|
validator.stop() // stop polling
|
||||||
|
```
|
||||||
|
|
||||||
|
The validator calls `/auth/api/validate` (see below) periodically to:
|
||||||
|
- Renew the session cookie (24h lifetime)
|
||||||
|
- Detect if the user logged out or switched accounts
|
||||||
|
- Pause polling when the page is idle, allowing sessions to expire when not used
|
||||||
|
|
||||||
|
### Manual Auth Flow
|
||||||
|
|
||||||
|
If you need custom control, handle 401/403 responses manually:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { showAuthIframe, AuthCancelledError } from 'paskia'
|
||||||
|
|
||||||
|
const response = await fetch('/api/protected')
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.auth?.iframe) {
|
||||||
|
try {
|
||||||
|
await showAuthIframe(data.auth.iframe)
|
||||||
|
// Retry the original request
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AuthCancelledError) {
|
||||||
|
// User clicked Back
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Info and Profile
|
||||||
|
|
||||||
|
Get current user details:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const user = await apiJson('/auth/api/user-info', { method: 'GET' })
|
||||||
|
// Returns: { uuid, display_name, credentials, sessions, permissions, ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Or link to the built-in profile page: `/auth/`
|
||||||
|
|
||||||
|
## Backend Integration
|
||||||
|
|
||||||
|
### Using Forward-Auth Headers
|
||||||
|
|
||||||
|
When using forward-auth, your backend receives `Remote-*` headers on authenticated requests. See [Headers](Headers.md) for the full list and [Forward-Auth Proxy Guides](proxy/index.md) for proxy configuration.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Example: Python/FastAPI
|
||||||
|
@app.get("/api/data")
|
||||||
|
def get_data(request: Request):
|
||||||
|
user_id = request.headers.get("Remote-User")
|
||||||
|
org_id = request.headers.get("Remote-Org")
|
||||||
|
permissions = request.headers.get("Remote-Groups", "").split(",")
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direct Validation from Backend
|
||||||
|
|
||||||
|
This is useful for:
|
||||||
|
- Apps/APIs not behind proxy Forward-Auth protection
|
||||||
|
- Background jobs that need to verify a stored session
|
||||||
|
- Check extra permissions, get user context or renew session
|
||||||
|
|
||||||
|
Your backend can validate sessions directly by calling Paskia's validate endpoint [`/auth/api/validate`](api/validate.md). It generally expects client headers proxied as is, while on the URL you can specify exact requirements.
|
||||||
|
|
||||||
|
Usually it is sufficient to simply forward the headers the client sent, assuming your proxy already preserved `Host` and set `X-Forwarded-For` (otherwise set them here with original host and IP). `User-Agent` should also be forwarded if available, omitted if not: do not let your backend HTTP client add its own header.
|
||||||
|
|
||||||
|
Be sure to REMOVE connection hop-by-hop headers (these will break WebSockets among other things):
|
||||||
|
|
||||||
|
```python
|
||||||
|
"Connection", "Keep-Alive", "Proxy-Connection", "TE", "Transfer-Encoding", "Upgrade"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Proxying /auth/ to Paskia
|
||||||
|
|
||||||
|
Your app server needs to proxy `/auth/` paths to Paskia. This can be done by your application but is much easier done by a reverse proxy. The [Forward-Auth Proxy Guides](proxy/index.md) cover Caddy, Nginx, Traefik, Apache APISIX, Envoy and HAProxy.
|
||||||
|
|
||||||
|
### Caddy
|
||||||
|
|
||||||
|
This handles both HTTP and WebSocket connections. Caddy's `reverse_proxy` handles HTTP and WebSocket transparently. This is essentially what our Caddy [auth/setup](../caddy/auth/setup) snippet does: `reverse_proxy :4401`.
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
app.example.com {
|
||||||
|
import auth/setup
|
||||||
|
# ... your routes in handle blocks
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx
|
||||||
|
|
||||||
|
Certain headers need to be configured for correct host and WS support:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /auth/ {
|
||||||
|
proxy_pass http://localhost:4401;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Node.js / Express
|
||||||
|
|
||||||
|
Using `http-proxy-middleware`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { createProxyMiddleware } from 'http-proxy-middleware'
|
||||||
|
|
||||||
|
app.use('/auth', createProxyMiddleware({ target: 'http://localhost:4401', ws: true, changeOrigin: false }))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python / FastAPI
|
||||||
|
|
||||||
|
You will need to process and handle `/auth/` for HTTP requests and `/auth/ws/` for WebSockets manually, which is beyond the scope of this documentation.
|
||||||
|
|
||||||
|
We highly recommend Caddy instead as the simpler and more production-worthy solution that Just Works.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# GET /auth/api/forward
|
||||||
|
|
||||||
|
[API overview](../API.md) · [Proxy guides](../proxy/index.md)
|
||||||
|
|
||||||
|
Forward-auth validation for reverse proxies. The proxy calls this endpoint for every incoming request; Paskia validates the session and either authorizes the request by returning 204 with `Remote-*` headers, or rejects it with 401/403 error responses with an HTML login page if `text/html` was requested (i.e. it's a browser viewing the page), or otherwise JSON with details on the error and a URL to initiate API authentication flow.
|
||||||
|
|
||||||
|
The proxy server follows the 204 response with the original request to protected service, adding those remote headers to original user request, sent to the service. Any error response is sent directly back to client, never connecting to the protected service.
|
||||||
|
|
||||||
|
See [Forward-Auth Proxy Guides](../proxy/index.md) for Caddy, Nginx, Traefik, Apache APISIX, Envoy and HAProxy configuration examples. Caddy users can also start from the dedicated [Caddy configuration guide](../proxy/caddy.md).
|
||||||
|
|
||||||
|
|
||||||
|
## Query parameters
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| perm | Required permissions. See the [perm argument](perm.md). |
|
||||||
|
| max_age | Require recent passkey use. See the [max_age argument](max-age.md). |
|
||||||
|
|
||||||
|
## Request headers
|
||||||
|
|
||||||
|
| Header | Expected value / note | How Paskia uses it |
|
||||||
|
|---|---|---|
|
||||||
|
| Host | Forwarded directly from the client | Verifying the session's bound host |
|
||||||
|
| Cookie | Forwarded directly or just cookie `__Host-paskia` | Session ID |
|
||||||
|
| X-Forwarded-Method | The HTTP method, e.g. POST | Logging of original request |
|
||||||
|
| X-Forwarded-Uri | Request path and query, e.g. /reports?foo=bar | Logging of original request |
|
||||||
|
| Accept | text/html or anything else | Determines whether failures return an HTML page or JSON |
|
||||||
|
|
||||||
|
Connection hop-by-hop headers (Connection, Upgrade, Transfer-Encoding, etc.) must not be forwarded.
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
### Success (204)
|
||||||
|
|
||||||
|
No response body. Only [Remote headers](../Headers.md) are set on the response and your proxy should forward them to the backend request. The headers, not a response body, are the whole point of this endpoint: they are how the authenticated identity reaches the protected service, which can trust them because it is only reachable through the proxy.
|
||||||
|
|
||||||
|
### Failure (400 / 401 / 403)
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| 400 | Malformed perm argument (see [perm](perm.md#syntax-errors)). The error detail names `/auth/api/forward` as the origin and never echoes query arguments. |
|
||||||
|
| 401 | Session missing or expired, or max_age not satisfied — the user needs to (re)authenticate. |
|
||||||
|
| 403 | Requested permissions are missing — the forbidden flow allows signing in with another account. |
|
||||||
|
|
||||||
|
Failure responses come in two flavors, chosen by the Accept header, because the audience differs:
|
||||||
|
|
||||||
|
- A browser asking for a page (Accept includes text/html) gets a full authentication page it can show directly — the proxy simply passes the response through and the user can sign in without any application involvement.
|
||||||
|
- Any other request (fetch, img, ...) gets JSON intended for programmatic handling. Besides the error detail, it carries an auth section whose iframe URL points to a ready-made authentication dialog your frontend can embed, so the user can sign in without leaving your app:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Additional authentication required",
|
||||||
|
"auth": {
|
||||||
|
"mode": "reauth",
|
||||||
|
"iframe": "/auth/restricted/iframe#mode=reauth&theme=dark"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The mode field:
|
||||||
|
- login: no valid session, need to sign in
|
||||||
|
- reauth: additional authentication required (using same passkey)
|
||||||
|
- forbidden: lacking required permissions
|
||||||
|
|
||||||
|
Extra metadata such as the user's theme override may appear as additional fields and iframe fragment parameters.
|
||||||
|
|
||||||
|
Note: we provide a JavaScript package [paskia](https://www.npmjs.com/package/paskia) with helpers for the embedding, fetch 401/403 handling, session renewals and more.
|
||||||
|
|
||||||
|
## Session renewal
|
||||||
|
|
||||||
|
The forward endpoint **does not renew** the session because in the forward-auth mechanism it could not send the client a renewed session cookie. It only validates the current cookie and returns the trusted headers. Use [/auth/api/validate](validate.md) when you need to refresh the session lifetime. Otherwise the user will have to sign in again every 24h even if they are actively using the service.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# The max_age argument
|
||||||
|
|
||||||
|
[API overview](../API.md) · [`/auth/api/validate`](validate.md) · [`/auth/api/forward`](../forward.md)
|
||||||
|
|
||||||
|
The max_age argument is used by endpoints that validate sessions to require a recent passkey use. It is supported by:
|
||||||
|
|
||||||
|
- [POST /auth/api/validate](validate.md)
|
||||||
|
- [GET /auth/api/forward](forward.md)
|
||||||
|
|
||||||
|
## What it checks
|
||||||
|
|
||||||
|
max_age limits how long ago the user last authenticated with their passkey. It is intended for high-risk actions where you want to be sure the user recently proved possession of their credential, not just that they still have a valid session cookie.
|
||||||
|
|
||||||
|
The check compares the elapsed time since the credential was last used against the given limit:
|
||||||
|
|
||||||
|
- If the credential has a last_used timestamp, that time is used.
|
||||||
|
- Otherwise, the session's validated timestamp is used as a fallback.
|
||||||
|
|
||||||
|
If the authentication is older than max_age, the endpoint returns 401 with mode reauth, prompting the user to re-authenticate.
|
||||||
|
|
||||||
|
## Time units
|
||||||
|
|
||||||
|
max_age accepts a number followed by one of these units:
|
||||||
|
|
||||||
|
| Unit | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| s | seconds |
|
||||||
|
| m / min | minutes |
|
||||||
|
| h | hours |
|
||||||
|
| d | days |
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?max_age=30s
|
||||||
|
?max_age=5m
|
||||||
|
?max_age=5min
|
||||||
|
?max_age=1h
|
||||||
|
?max_age=1d
|
||||||
|
```
|
||||||
|
|
||||||
|
An invalid format is logged as a warning but does not cause the request to fail; the requirement is simply ignored in that case.
|
||||||
|
|
||||||
|
## Important notes
|
||||||
|
|
||||||
|
- Session renewal by /auth/api/validate does **not** count as fresh authentication. The check is based on the credential's last_used time, not on how recently the session cookie was renewed.
|
||||||
|
- max_age is independent of perm. You can use either or both at the same time.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# The perm argument
|
||||||
|
|
||||||
|
[API overview](../API.md) · [`/auth/api/validate`](validate.md) · [`/auth/api/forward`](../forward.md)
|
||||||
|
|
||||||
|
The perm argument is used by endpoints that validate sessions to require one or more permission scopes. It is supported by:
|
||||||
|
|
||||||
|
- [POST /auth/api/validate](validate.md)
|
||||||
|
- [GET /auth/api/forward](forward.md)
|
||||||
|
|
||||||
|
## Passing the argument
|
||||||
|
|
||||||
|
Repeat the query parameter for each required scope:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?perm=myapp:read&perm=myapp:write
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also pass multiple scopes in one parameter by separating them with whitespace (a literal space, i.e. `+` or `%20` in the query string):
|
||||||
|
|
||||||
|
```text
|
||||||
|
?perm=myapp:read%20myapp:write
|
||||||
|
```
|
||||||
|
|
||||||
|
Both forms produce the same result.
|
||||||
|
|
||||||
|
## Semantics
|
||||||
|
|
||||||
|
The perm argument uses **AND** semantics between groups: **every** listed scope group must be satisfied by the effective permissions for the request to succeed. If any required group is not satisfied, the endpoint returns 403.
|
||||||
|
|
||||||
|
Within a single group, use `|` to list alternatives with **OR** semantics — the group is satisfied when **any one** of the alternatives is present:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?perm=myapp:read|myapp:write+myapp:login
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires `myapp:login` **and** (`myapp:read` **or** `myapp:write`). No whitespace is allowed around the `|` operator.
|
||||||
|
|
||||||
|
## Syntax errors
|
||||||
|
|
||||||
|
Parsing is strict: anything out of spec is rejected with **400 Bad Request** rather than guessed at. This includes:
|
||||||
|
|
||||||
|
- Empty values or alternatives (`?perm=`, `?perm=a||b`, `?perm=|a`, `?perm=a|`)
|
||||||
|
- Whitespace around `|` (`?perm=a+|+b`)
|
||||||
|
- Characters not allowed in scopes other than the operators (space and `|`); scopes match `^[A-Za-z0-9:._~/-]+$` plus the `*` wildcard. In particular a literal `+` in the decoded value (from a `%2B` in the query string) is rejected — use `+` or `%20` to encode a space, never `%2B`.
|
||||||
|
|
||||||
|
Extra spaces between groups (leading, trailing, or repeated) are tolerated, since they can easily result from URL formatting and carry no ambiguity — they only ever add required permissions, never remove them. The `|` operator is parsed strictly: `?perm=foo&perm=|bar` is an error, never a way to make `foo` optional.
|
||||||
|
|
||||||
|
## Wildcards
|
||||||
|
|
||||||
|
Wildcards work like filenames, with `:` and `/` acting as path separators:
|
||||||
|
|
||||||
|
- `*` matches any sequence of characters **within a single segment** (it never crosses a `:` or `/`)
|
||||||
|
- `**` matches any sequence of characters, **across separators**
|
||||||
|
- Part of a segment can be wildcarded, with required text on either or both sides
|
||||||
|
|
||||||
|
```text
|
||||||
|
?perm=myapp:*
|
||||||
|
```
|
||||||
|
|
||||||
|
This matches myapp:read and myapp:write, but **not** myapp:read:all — use `myapp:**` for that. Partial wildcards like `myapp:re*` or `myapp:r*d` match myapp:read. The same applies to path-based scopes: `myapp:path:/api/*` matches myapp:path:/api/clients but not myapp:path:/api/v2/clients — use `myapp:path:/api/**` to span path segments.
|
||||||
|
|
||||||
|
## Effective permissions
|
||||||
|
|
||||||
|
The permissions available to a session are determined as follows:
|
||||||
|
|
||||||
|
1. **Role permissions** — the role assigned to the user contains a set of permission UUIDs.
|
||||||
|
2. **Org grantable permissions** — only permissions that the user's organization is allowed to grant are effective.
|
||||||
|
3. **Domain filtering** — a permission can be restricted to a specific domain via its domain field. If the request's Host header does not match that domain, the permission is excluded.
|
||||||
|
|
||||||
|
The result is the set of effective permission scopes used for the perm check. Domain-restricted permissions let you grant a scope only for a specific site or subdomain without making it global.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Require a single permission:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?perm=myapp:login
|
||||||
|
```
|
||||||
|
|
||||||
|
Require two permissions:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?perm=myapp:login&perm=myapp:api
|
||||||
|
```
|
||||||
|
|
||||||
|
Require any scope under myapp:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?perm=myapp:*
|
||||||
|
```
|
||||||
|
|
||||||
|
Require myapp:login and either myapp:read or myapp:write:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?perm=myapp:login&perm=myapp:read|myapp:write
|
||||||
|
```
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# POST /auth/api/validate
|
||||||
|
|
||||||
|
[API overview](../API.md) · [`/auth/api/forward`](../forward.md) · [max_age](max-age.md) · [perm](perm.md)
|
||||||
|
|
||||||
|
Validate a session and renew its lifetime when needed. This endpoint is normally called by the browser/session validator, but backends may also call it directly when not behind a forward-auth proxy.
|
||||||
|
|
||||||
|
See also the [API overview](../API.md) and the [integration guide](../Integration.md).
|
||||||
|
|
||||||
|
## Query parameters
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| perm | Required permissions. See the [perm argument](perm.md). |
|
||||||
|
| max_age | Require recent passkey use. See the [max_age argument](max-age.md). |
|
||||||
|
|
||||||
|
## Request headers
|
||||||
|
|
||||||
|
| Header | Expected value / note | How Paskia uses it |
|
||||||
|
|---|---|---|
|
||||||
|
| Host | Forwarded directly from the client | Verifying the session's bound host |
|
||||||
|
| Cookie | Forwarded directly or just cookie `__Host-paskia` | Session ID |
|
||||||
|
| X-Forwarded-For | Real client IP | Recorded in session data instead of the backend/proxy IP; requires FORWARDED_ALLOW_IPS to trust the immediate peer |
|
||||||
|
| User-Agent | Forward the original client UA if available; do not let your backend client add its own default | Recorded in session data only when the header is present; omitting it preserves the existing value |
|
||||||
|
|
||||||
|
See [integration documentation for backend validate requests](../Integration.md) for more detailed instructions, in particular for forwarding of client-provided headers.
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
The endpoint always responds with JSON.
|
||||||
|
|
||||||
|
### Success (200)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"valid": true,
|
||||||
|
"renewed": false,
|
||||||
|
"ctx": {
|
||||||
|
"user": {
|
||||||
|
"uuid": "...",
|
||||||
|
"display_name": "John Smith",
|
||||||
|
"theme": "dark"
|
||||||
|
},
|
||||||
|
"org": {
|
||||||
|
"uuid": "...",
|
||||||
|
"display_name": "The Company Ltd."
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"uuid": "...",
|
||||||
|
"display_name": "Employee"
|
||||||
|
},
|
||||||
|
"permissions": ["auth:admin", "myapp:login"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the response includes a Set-Cookie header, the session has been renewed (renewed is true) and you should forward that cookie to the client so the browser updates its expiry. Not forwarding it means the session lifetime is not extended, so the user may need to re-authenticate sooner. Renewals are throttled, so frequent calls usually return renewed: false with no Set-Cookie.
|
||||||
|
|
||||||
|
### Failure (400 / 401 / 403)
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| 400 | Malformed perm argument (see [perm](perm.md#syntax-errors)). |
|
||||||
|
| 401 | Session missing, expired, or max_age not satisfied. The response body includes auth metadata for the login/reauth iframe. |
|
||||||
|
| 403 | Session is valid but one or more requested permissions are missing. |
|
||||||
|
|
||||||
|
A failure response never contains a refreshed Set-Cookie.
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Apache APISIX Forward-Auth
|
||||||
|
|
||||||
|
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
|
||||||
|
|
||||||
|
This guide uses the Apache APISIX [`forward-auth`](https://apisix.apache.org/docs/apisix/plugins/forward-auth/) plugin to ask Paskia whether each request is allowed.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
APISIX adds the standard `X-Forwarded-*` headers automatically, but we still tell the plugin to forward `Host`, `Cookie`, and `Accept` from the client request. On a `204` response from Paskia we copy the `Remote-*` headers to the backend request.
|
||||||
|
|
||||||
|
## Admin API example
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Route that proxies /auth/ to Paskia without forward-auth.
|
||||||
|
curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
|
||||||
|
-H "X-API-KEY: ${admin_key}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"id": "paskia-auth-ui",
|
||||||
|
"uri": "/auth/*",
|
||||||
|
"upstream": {
|
||||||
|
"nodes": { "localhost:4401": 1 },
|
||||||
|
"type": "roundrobin"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Protected route that uses Paskia forward-auth.
|
||||||
|
curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
|
||||||
|
-H "X-API-KEY: ${admin_key}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"id": "app-protected",
|
||||||
|
"uri": "/*",
|
||||||
|
"priority": 10,
|
||||||
|
"plugins": {
|
||||||
|
"forward-auth": {
|
||||||
|
"uri": "http://localhost:4401/auth/api/forward?perm=myapp:login",
|
||||||
|
"request_headers": [
|
||||||
|
"Host",
|
||||||
|
"Cookie",
|
||||||
|
"Accept",
|
||||||
|
"X-Forwarded-Method",
|
||||||
|
"X-Forwarded-Uri",
|
||||||
|
"X-Forwarded-Host",
|
||||||
|
"X-Forwarded-Proto",
|
||||||
|
"X-Forwarded-For"
|
||||||
|
],
|
||||||
|
"upstream_headers": [
|
||||||
|
"Remote-User",
|
||||||
|
"Remote-Name",
|
||||||
|
"Remote-Groups",
|
||||||
|
"Remote-Org",
|
||||||
|
"Remote-Org-Name",
|
||||||
|
"Remote-Role",
|
||||||
|
"Remote-Role-Name",
|
||||||
|
"Remote-Session-Expires",
|
||||||
|
"Remote-Credential"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"upstream": {
|
||||||
|
"nodes": { "localhost:3000": 1 },
|
||||||
|
"type": "roundrobin"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The `paskia-auth-ui` route has a higher priority (`priority` defaults to the same value for both routes; you can also rely on the more specific `/auth/*` URI matching first). Because it does not use the `forward-auth` plugin, users can reach the login/profile pages without already being authenticated.
|
||||||
|
|
||||||
|
## ADC / declarative example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
- name: paskia-auth-ui
|
||||||
|
routes:
|
||||||
|
- name: auth-route
|
||||||
|
uris:
|
||||||
|
- /auth/*
|
||||||
|
upstream:
|
||||||
|
type: roundrobin
|
||||||
|
nodes:
|
||||||
|
- host: localhost
|
||||||
|
port: 4401
|
||||||
|
weight: 1
|
||||||
|
|
||||||
|
- name: app-protected
|
||||||
|
routes:
|
||||||
|
- name: app-route
|
||||||
|
uris:
|
||||||
|
- /*
|
||||||
|
plugins:
|
||||||
|
forward-auth:
|
||||||
|
uri: http://localhost:4401/auth/api/forward?perm=myapp:login
|
||||||
|
request_headers:
|
||||||
|
- Host
|
||||||
|
- Cookie
|
||||||
|
- Accept
|
||||||
|
- X-Forwarded-Method
|
||||||
|
- X-Forwarded-Uri
|
||||||
|
- X-Forwarded-Host
|
||||||
|
- X-Forwarded-Proto
|
||||||
|
- X-Forwarded-For
|
||||||
|
upstream_headers:
|
||||||
|
- Remote-User
|
||||||
|
- Remote-Name
|
||||||
|
- Remote-Groups
|
||||||
|
- Remote-Org
|
||||||
|
- Remote-Org-Name
|
||||||
|
- Remote-Role
|
||||||
|
- Remote-Role-Name
|
||||||
|
- Remote-Session-Expires
|
||||||
|
- Remote-Credential
|
||||||
|
upstream:
|
||||||
|
type: roundrobin
|
||||||
|
nodes:
|
||||||
|
- host: localhost
|
||||||
|
port: 3000
|
||||||
|
weight: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply it with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
adc sync -f paskia.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## What APISIX sends to Paskia
|
||||||
|
|
||||||
|
APISIX automatically adds these headers to the auth request:
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|---|---|
|
||||||
|
| `X-Forwarded-Method` | Original HTTP method |
|
||||||
|
| `X-Forwarded-Proto` | Request scheme (`http`/`https`) |
|
||||||
|
| `X-Forwarded-Host` | Original host |
|
||||||
|
| `X-Forwarded-Uri` | Original request URI |
|
||||||
|
| `X-Forwarded-For` | Client IP address |
|
||||||
|
|
||||||
|
We list them again in `request_headers` to make sure they are not accidentally filtered out when the list is explicit.
|
||||||
|
|
||||||
|
## Response headers
|
||||||
|
|
||||||
|
`upstream_headers` lists the `Remote-*` headers that APISIX copies from the auth response to the backend request. The plugin does not support a wildcard here, so each header must be named.
|
||||||
|
|
||||||
|
If you want Paskia's failure-response headers (such as `Content-Type` or `Set-Cookie`) to reach the client, list them in `client_headers`. For Paskia this is usually not needed; the response body already contains the JSON auth URL or the HTML login page.
|
||||||
|
|
||||||
|
## Adjusting requirements
|
||||||
|
|
||||||
|
Change the `uri` query string to require different permissions or recent authentication:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
uri: http://localhost:4401/auth/api/forward?perm=myapp:login
|
||||||
|
uri: http://localhost:4401/auth/api/forward?perm=myapp:admin&max_age=5min
|
||||||
|
uri: http://localhost:4401/auth/api/forward
|
||||||
|
```
|
||||||
|
|
||||||
|
The last form requires only authentication. See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The auth request is `GET` by default. Since the `forward-auth` plugin does not forward the request body unless `request_method` is set to `POST`, the default `GET` is the right choice for Paskia.
|
||||||
|
- Hop-by-hop headers are handled by APISIX when it builds the auth request, so no extra configuration is needed for `Connection`/`Upgrade`.
|
||||||
|
- If Paskia is running on a different host, replace `localhost:4401` with the Paskia service address. For a dedicated authentication host (`--auth-host`), route `auth.example.com` to Paskia instead of `/auth/`.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Paskia Caddy Configuration
|
||||||
|
|
||||||
|
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
|
||||||
|
|
||||||
|
[Caddy](https://caddyserver.com/) is a modern web server that makes setting up web services easy. We provide a few Caddy snippets that make the configuration even easier, although the `forward_auth` directive of Caddy can be used directly as well. Place the [auth folder](../../caddy/auth) with the snippets `require` and `setup` where your config file is (e.g. `/etc/caddy/auth`)
|
||||||
|
|
||||||
|
What these snippets do
|
||||||
|
- `setup`: Mount the auth UI at `/auth/` proxying to `:4401`
|
||||||
|
- `require`: Use `/auth/api/forward` for access control
|
||||||
|
- Render a login page or a permission denied page if needed (without changing URL)
|
||||||
|
|
||||||
|
Your backend may not use authentication at all, or it can make use of the user information passed via `Remote-*` headers by the authentication system, see [trusted headers](../Headers.md) for details.
|
||||||
|
|
||||||
|
We assume the normal unprotected **Caddyfile** for your site looks like this:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
app.example.com {
|
||||||
|
@public path /.well-known/* /favicon.ico
|
||||||
|
handle @public {
|
||||||
|
root * /var/www/
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy :3000 # Your app backend
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: We use the `handle @name` approach rather than `handle_path` to keep the path unaltered. Unlike bare directives, these blocks will be tried in sequence and each can contain what you'd typically put in your site definition (by default `reverse_proxy` takes precedence and nothing reaches the static files).
|
||||||
|
|
||||||
|
We will adapt from this to protect your app.
|
||||||
|
|
||||||
|
### Protect your site (auth/setup, auth/require)
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
app.example.com {
|
||||||
|
import auth/setup
|
||||||
|
|
||||||
|
@public path /.well-known/* /favicon.ico
|
||||||
|
handle @public {
|
||||||
|
root * /var/www/
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
@reports path /reports
|
||||||
|
handle @reports {
|
||||||
|
import auth/require perm=myapp:reports
|
||||||
|
reverse_proxy :3000
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
import auth/require max-age=12h
|
||||||
|
reverse_proxy :3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The above setup allows unauthenticated access to certain files, then implements two different access controls for your backend app depending on which path is accessed. Note that the perm and max-age options may be combined, e.g. `perm=myapp:admin&max-age=5min` on a very sensitive endpoint. This will require additional authentication if the passkey hasn't been used in the last 5 minutes (automatic session renewals don't affect this). Use `""` if you only want the user to be authenticated with no time or perm requirements.
|
||||||
|
|
||||||
|
### Dedicated Authentication Site
|
||||||
|
|
||||||
|
When you setup a separate subdomain for the authentication site, just add to your config another section for the auth host:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
auth.example.com {
|
||||||
|
reverse_proxy :4401
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remember to specify `paskia serve --auth-host auth.example.com` to restrict the authentication services to this domain.
|
||||||
|
|
||||||
|
Note that we still reserve `/auth/` on each site for logout page and any APIs your application may require, while full user profile and global options are only available on the auth host.
|
||||||
|
|
||||||
|
Paskia does not require CORS configuration, but it can access the authentication and registration of auth host WS API from the other sites as WebSockets don't require any CORS.
|
||||||
|
|
||||||
|
### Override the paskia backend address (AUTH_UPSTREAM)
|
||||||
|
|
||||||
|
By default, the auth service is contacted at localhost port 4401. You can point Caddy to a different address by setting the `AUTH_UPSTREAM` environment variable for Caddy.
|
||||||
|
|
||||||
|
If unset, the snippets use `:4401` by default.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Envoy External Authorization (ext_authz)
|
||||||
|
|
||||||
|
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
|
||||||
|
|
||||||
|
This guide uses Envoy's [external authorization filter](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter) (`ext_authz`) to ask Paskia whether each request is allowed.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Envoy's `ext_authz` HTTP filter calls an external HTTP service before forwarding a request to the upstream. The filter needs to know which headers from the original request to send to Paskia, and which headers from Paskia's response to add to the upstream request or to the client response.
|
||||||
|
|
||||||
|
A minimal static configuration looks like this:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
static_resources:
|
||||||
|
listeners:
|
||||||
|
- name: app_listener
|
||||||
|
address:
|
||||||
|
socket_address:
|
||||||
|
address: 0.0.0.0
|
||||||
|
port_value: 8080
|
||||||
|
filter_chains:
|
||||||
|
- filters:
|
||||||
|
- name: envoy.filters.network.http_connection_manager
|
||||||
|
typed_config:
|
||||||
|
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
|
||||||
|
stat_prefix: ingress_http
|
||||||
|
use_remote_address: true
|
||||||
|
route_config:
|
||||||
|
name: local_route
|
||||||
|
virtual_hosts:
|
||||||
|
- name: app
|
||||||
|
domains: ["*"]
|
||||||
|
routes:
|
||||||
|
# Pass /auth/ straight to Paskia, bypassing ext_authz.
|
||||||
|
- match:
|
||||||
|
prefix: "/auth/"
|
||||||
|
route:
|
||||||
|
cluster: paskia
|
||||||
|
typed_per_filter_config:
|
||||||
|
envoy.filters.http.ext_authz:
|
||||||
|
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
|
||||||
|
disabled: true
|
||||||
|
|
||||||
|
# Protect everything else.
|
||||||
|
- match:
|
||||||
|
prefix: "/"
|
||||||
|
route:
|
||||||
|
cluster: app_backend
|
||||||
|
http_filters:
|
||||||
|
- name: envoy.filters.http.ext_authz
|
||||||
|
typed_config:
|
||||||
|
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
|
||||||
|
transport_api_version: v3
|
||||||
|
http_service:
|
||||||
|
server_uri:
|
||||||
|
uri: localhost:4401
|
||||||
|
cluster: paskia
|
||||||
|
timeout: 0.5s
|
||||||
|
# Send every auth check to /auth/api/forward with the
|
||||||
|
# required permission, regardless of the original path.
|
||||||
|
path_override: "/auth/api/forward?perm=myapp:login"
|
||||||
|
authorization_request:
|
||||||
|
allowed_headers:
|
||||||
|
patterns:
|
||||||
|
- exact: Host
|
||||||
|
- exact: Cookie
|
||||||
|
- exact: Accept
|
||||||
|
# Add the headers Paskia logs / expects.
|
||||||
|
headers_to_add:
|
||||||
|
- key: X-Forwarded-Method
|
||||||
|
value: "%REQ(:METHOD)%"
|
||||||
|
- key: X-Forwarded-Uri
|
||||||
|
value: "%REQ(:PATH)%"
|
||||||
|
- key: X-Forwarded-Proto
|
||||||
|
value: "%REQ(:SCHEME)%"
|
||||||
|
- key: X-Forwarded-For
|
||||||
|
value: "%REQ(X-Forwarded-For)%"
|
||||||
|
authorization_response:
|
||||||
|
# Forward every Remote-* header to the backend.
|
||||||
|
allowed_upstream_headers:
|
||||||
|
patterns:
|
||||||
|
- prefix: Remote-
|
||||||
|
# Forward the response Content-Type to the client on 401/403.
|
||||||
|
allowed_client_headers:
|
||||||
|
patterns:
|
||||||
|
- exact: Content-Type
|
||||||
|
failure_mode_allow: false
|
||||||
|
- name: envoy.filters.http.router
|
||||||
|
typed_config:
|
||||||
|
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
|
||||||
|
|
||||||
|
clusters:
|
||||||
|
- name: app_backend
|
||||||
|
connect_timeout: 0.25s
|
||||||
|
type: logical_dns
|
||||||
|
lb_policy: round_robin
|
||||||
|
load_assignment:
|
||||||
|
cluster_name: app_backend
|
||||||
|
endpoints:
|
||||||
|
- lb_endpoints:
|
||||||
|
- endpoint:
|
||||||
|
address:
|
||||||
|
socket_address:
|
||||||
|
address: localhost
|
||||||
|
port_value: 3000
|
||||||
|
|
||||||
|
- name: paskia
|
||||||
|
connect_timeout: 0.25s
|
||||||
|
type: logical_dns
|
||||||
|
lb_policy: round_robin
|
||||||
|
load_assignment:
|
||||||
|
cluster_name: paskia
|
||||||
|
endpoints:
|
||||||
|
- lb_endpoints:
|
||||||
|
- endpoint:
|
||||||
|
address:
|
||||||
|
socket_address:
|
||||||
|
address: localhost
|
||||||
|
port_value: 4401
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important configuration details
|
||||||
|
|
||||||
|
- **`path_override`** — the auth request always goes to `/auth/api/forward?perm=myapp:login`, no matter which path the client requested. The original path is sent in `X-Forwarded-Uri` for Paskia to log.
|
||||||
|
- **`authorization_request.allowed_headers`** — Envoy only forwards the headers you explicitly allow. We allow `Host`, `Cookie`, and `Accept`. The `X-Forwarded-*` headers are added via `headers_to_add` so they are based on Envoy's view of the request, not spoofed client values.
|
||||||
|
- **`headers_to_add`** — Envoy supports substitution format strings such as `%REQ(:METHOD)%` and `%REQ(:PATH)%`. These set the headers Paskia uses for logging and host validation.
|
||||||
|
- **`authorization_response.allowed_upstream_headers`** — `prefix: Remote-` tells Envoy to copy every response header starting with `Remote-` to the upstream request. This also removes any client-supplied `Remote-*` headers, so the backend can trust them.
|
||||||
|
- **`authorization_response.allowed_client_headers`** — on a 401/403 response, Envoy forwards only the allowed response headers to the client. Paskia returns HTML or JSON with a `Content-Type` header, so we allow that. (Paskia does not set cookies on the forward-auth response.)
|
||||||
|
- **`typed_per_filter_config`** on the `/auth/` route disables `ext_authz` so users can reach the login/profile pages without already being authenticated.
|
||||||
|
|
||||||
|
## Per-route requirements
|
||||||
|
|
||||||
|
Different routes often need different permissions or `max_age` values. Use `typed_per_filter_config` on each route to override the `http_service.path_override`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
routes:
|
||||||
|
- match:
|
||||||
|
prefix: "/reports"
|
||||||
|
route:
|
||||||
|
cluster: app_backend
|
||||||
|
typed_per_filter_config:
|
||||||
|
envoy.filters.http.ext_authz:
|
||||||
|
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
|
||||||
|
check_settings:
|
||||||
|
http_service:
|
||||||
|
path_override: "/auth/api/forward?perm=myapp:reports&max_age=5min"
|
||||||
|
|
||||||
|
- match:
|
||||||
|
prefix: "/"
|
||||||
|
route:
|
||||||
|
cluster: app_backend
|
||||||
|
typed_per_filter_config:
|
||||||
|
envoy.filters.http.ext_authz:
|
||||||
|
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
|
||||||
|
check_settings:
|
||||||
|
http_service:
|
||||||
|
path_override: "/auth/api/forward?perm=myapp:login"
|
||||||
|
```
|
||||||
|
|
||||||
|
See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) for query parameter syntax.
|
||||||
|
|
||||||
|
## WebSocket support for `/auth/`
|
||||||
|
|
||||||
|
If you use a dedicated authentication host (`--auth-host`), route `auth.example.com` to the Paskia cluster and you do not need the `/auth/` bypass above. Otherwise, make sure the `/auth/` route keeps the `Upgrade` and `Connection` headers so passkey WebSocket endpoints work. The default Envoy router handles `Upgrade` headers when the client requests them.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Envoy's `ext_authz` filter does not send the request body to the auth server by default. For Paskia this is fine.
|
||||||
|
- If Paskia is running behind TLS, use `https://` in `server_uri.uri` and configure the cluster's transport socket.
|
||||||
|
- The `failure_mode_allow: false` setting means that if Paskia cannot be reached, Envoy will reject the request. In testing you may prefer `true`, but use `false` in production so a failed auth service cannot accidentally allow traffic.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# HAProxy Forward-Auth
|
||||||
|
|
||||||
|
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
|
||||||
|
|
||||||
|
HAProxy does not have a built-in forward-auth primitive, but the community [`haproxy-auth-request`](https://github.com/TimWolla/haproxy-auth-request) Lua script provides an `auth-intercept` action that works very similarly to Nginx's `auth_request`. It makes an internal HTTP request to Paskia and copies the response headers to the backend request.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- HAProxy 2.2 or newer (2.0+ may work but 2.2+ supports all features shown here).
|
||||||
|
- Compiled with `USE_LUA=1`.
|
||||||
|
- The [`haproxy-auth-request`](https://github.com/TimWolla/haproxy-auth-request) Lua script loaded.
|
||||||
|
- The [`haproxy-lua-http`](https://github.com/haproxytech/haproxy-lua-http) dependency in the Lua path.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
global
|
||||||
|
lua-load /usr/share/haproxy/auth-request.lua
|
||||||
|
|
||||||
|
defaults
|
||||||
|
mode http
|
||||||
|
timeout connect 5s
|
||||||
|
timeout client 30s
|
||||||
|
timeout server 30s
|
||||||
|
|
||||||
|
# Backend that runs the Paskia auth check.
|
||||||
|
backend paskia_auth
|
||||||
|
server paskia 127.0.0.1:4401
|
||||||
|
|
||||||
|
# Backend that runs the Paskia UI / WebSocket / API.
|
||||||
|
backend paskia_ui
|
||||||
|
server paskia 127.0.0.1:4401
|
||||||
|
|
||||||
|
# Your protected application.
|
||||||
|
backend app_backend
|
||||||
|
server app 127.0.0.1:3000
|
||||||
|
|
||||||
|
frontend app
|
||||||
|
bind *:80
|
||||||
|
|
||||||
|
# 1. Route /auth/ straight to Paskia, bypassing the auth check.
|
||||||
|
acl is_auth path_beg /auth/
|
||||||
|
use_backend paskia_ui if is_auth
|
||||||
|
|
||||||
|
# 2. Add the headers Paskia logs / expects. These are then copied to the
|
||||||
|
# auth subrequest by auth-intercept.
|
||||||
|
http-request set-header X-Forwarded-Method %[method]
|
||||||
|
http-request set-header X-Forwarded-Uri %[url]
|
||||||
|
|
||||||
|
# 3. Run the auth check. The parameters are:
|
||||||
|
# backend path method
|
||||||
|
# req-headers success-headers failure-headers
|
||||||
|
#
|
||||||
|
# - req-headers: headers copied from client to Paskia.
|
||||||
|
# - success-headers: headers copied from Paskia response to backend request.
|
||||||
|
# - failure-headers: headers copied from Paskia response to client response.
|
||||||
|
http-request lua.auth-intercept paskia_auth /auth/api/forward?perm=myapp:login GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* *
|
||||||
|
|
||||||
|
# 4. If the subrequest was not successful, deny the request.
|
||||||
|
http-request deny if ! { var(txn.auth_response_successful) -m bool }
|
||||||
|
|
||||||
|
default_backend app_backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## What the configuration does
|
||||||
|
|
||||||
|
1. **Route `/auth/` to Paskia** — users must be able to reach the login/profile UI without already being authenticated. HAProxy will proxy WebSocket upgrade headers automatically for this backend when the client requests them.
|
||||||
|
2. **Set `X-Forwarded-Method` and `X-Forwarded-Uri`** — HAProxy adds these headers to the incoming request so the Lua script can copy them to the auth subrequest. `%[method]` returns the HTTP method and `%[url]` returns the path and query string.
|
||||||
|
3. **`lua.auth-intercept`** — sends a `GET` request to `/auth/api/forward?perm=myapp:login` on the `paskia_auth` backend. It copies the listed request headers (including the dynamic ones we just set) to the auth subrequest.
|
||||||
|
4. **On success (`2xx`)** — copies every response header matching `Remote-*` from Paskia to the backend request. This overrides any client-supplied `Remote-*` headers, so the backend can trust them.
|
||||||
|
5. **On failure (`4xx`)** — copies all response headers (`*`) to the client response and uses Paskia's response body, so the browser gets the login HTML or the JSON auth URL.
|
||||||
|
6. **Deny if auth failed** — the final `http-request deny` rule is a safety net. In practice, `auth-intercept` with `*` as the failure-headers already terminates the transaction with Paskia's response.
|
||||||
|
|
||||||
|
## Backend definition for the auth subrequest
|
||||||
|
|
||||||
|
The `paskia_auth` backend can be the same physical server as `paskia_ui`, but using a separate backend is convenient because the Lua script will use the first available server in the backend. The auth subrequest is a plain HTTP request, so no special WebSocket options are needed here.
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
backend paskia_auth
|
||||||
|
server paskia 127.0.0.1:4401
|
||||||
|
```
|
||||||
|
|
||||||
|
## Per-route permissions
|
||||||
|
|
||||||
|
You can run different auth checks for different paths by using HAProxy ACLs. Place the more specific rules before the generic one:
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
frontend app
|
||||||
|
bind *:80
|
||||||
|
|
||||||
|
acl is_auth path_beg /auth/
|
||||||
|
use_backend paskia_ui if is_auth
|
||||||
|
|
||||||
|
acl is_reports path_beg /reports
|
||||||
|
http-request set-header X-Forwarded-Method %[method] if is_reports
|
||||||
|
http-request set-header X-Forwarded-Uri %[url] if is_reports
|
||||||
|
http-request lua.auth-intercept paskia_auth /auth/api/forward?perm=myapp:reports&max_age=5min GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* * if is_reports
|
||||||
|
|
||||||
|
http-request set-header X-Forwarded-Method %[method]
|
||||||
|
http-request set-header X-Forwarded-Uri %[url]
|
||||||
|
http-request lua.auth-intercept paskia_auth /auth/api/forward?perm=myapp:login GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* *
|
||||||
|
|
||||||
|
http-request deny if ! { var(txn.auth_response_successful) -m bool }
|
||||||
|
default_backend app_backend
|
||||||
|
```
|
||||||
|
|
||||||
|
See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) for query parameter syntax.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The Lua script strips the request body from the auth subrequest, so Paskia's `/auth/api/forward` will only see the headers.
|
||||||
|
- HAProxy variables are limited to alphanumeric characters, dots, and underscores, but the script already normalizes header names for you (e.g. `Remote-User` becomes `req.auth_response_header.remote_user`). The `Remote-*` glob pattern in the success-headers argument handles this automatically.
|
||||||
|
- The auth backend must be reachable without TLS. If you need TLS to Paskia, run a local TCP forwarder or use HAProxy's Lua HTTP support directly (not covered by this script).
|
||||||
|
- If you use a dedicated authentication host (`--auth-host`), route `auth.example.com` to the Paskia backend and start Paskia with `--auth-host auth.example.com` instead of exposing `/auth/` on every site.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Forward-Auth Proxy Guides
|
||||||
|
|
||||||
|
[`/auth/api/forward`](../api/forward.md) · [Trusted headers](../Headers.md)
|
||||||
|
|
||||||
|
These guides show how to protect a backend application with Paskia using the forward-auth (also called "external authentication") mechanism. The reverse proxy asks Paskia whether a request is allowed before forwarding it to the protected service.
|
||||||
|
|
||||||
|
For details about the endpoint the proxy calls, see [`/auth/api/forward`](../api/forward.md). For the headers your backend receives on successful requests, see [Trusted Headers](../Headers.md).
|
||||||
|
|
||||||
|
## Available guides
|
||||||
|
|
||||||
|
- [Caddy](caddy.md) — fully supported with ready-to-use snippets (`auth/setup` and `auth/require`).
|
||||||
|
- [Nginx](nginx.md) — using the `auth_request` module.
|
||||||
|
- [Traefik](traefik.md) — using the `ForwardAuth` middleware.
|
||||||
|
- [Apache APISIX](apisix.md) — using the `forward-auth` plugin.
|
||||||
|
- [Envoy](envoy.md) — using the `ext_authz` HTTP filter.
|
||||||
|
- [HAProxy](haproxy.md) — using a Lua auth request.
|
||||||
|
|
||||||
|
## Common requirements
|
||||||
|
|
||||||
|
No matter which proxy you use, the auth subrequest must:
|
||||||
|
|
||||||
|
1. Be sent to `GET /auth/api/forward` on the Paskia backend. By default Paskia listens on `localhost:4401`; set the `AUTH_UPSTREAM` environment variable in our Caddy snippets, or point your proxy at wherever Paskia is running.
|
||||||
|
2. Include the query parameters Paskia needs for access control:
|
||||||
|
- `perm` — required permission scope, repeatable (e.g. `perm=myapp:login`). See [perm argument](../api/perm.md).
|
||||||
|
- `max_age` — how recently the user must have authenticated (e.g. `max_age=5min`). See [max_age argument](../api/max-age.md).
|
||||||
|
3. Forward these request headers from the original client request:
|
||||||
|
- `Host` — the site the user is visiting.
|
||||||
|
- `Cookie` — the session cookie, normally `__Host-paskia`.
|
||||||
|
- `X-Forwarded-Method` — the original HTTP method (e.g. `GET`, `POST`).
|
||||||
|
- `X-Forwarded-Uri` — the original path and query string (e.g. `/reports?foo=bar`).
|
||||||
|
- `Accept` — decides whether a 401/403 response should be HTML (browser) or JSON (API/fetch).
|
||||||
|
4. Strip hop-by-hop headers (`Connection`, `Upgrade`, `Transfer-Encoding`, `Keep-Alive`, `Proxy-Connection`, `TE`) from the auth subrequest. The auth check is a plain HTTP request and must not carry WebSocket/body framing headers.
|
||||||
|
5. On a `204 No Content` response, copy the `Remote-*` response headers to the request that is forwarded to the protected backend. The headers are the whole point of the auth check.
|
||||||
|
6. On a 401/403 response, send Paskia's response back to the client without contacting the protected backend.
|
||||||
|
7. Also proxy the `/auth/` path prefix to Paskia so the login/profile UI, API endpoints, and WebSockets are reachable. Paskia's WebSocket endpoints need `Upgrade` and `Connection` headers passed through for that path.
|
||||||
|
|
||||||
|
## Backend usage
|
||||||
|
|
||||||
|
After the proxy forwards the request, your backend can read the trusted headers. For example, in Python/FastAPI:
|
||||||
|
|
||||||
|
```python
|
||||||
|
user_id = request.headers.get("Remote-User")
|
||||||
|
org_id = request.headers.get("Remote-Org")
|
||||||
|
permissions = request.headers.get("Remote-Groups", "").split(",")
|
||||||
|
```
|
||||||
|
|
||||||
|
Only trust headers that come from the proxy; never trust `Remote-*` headers that arrive directly from the internet. Your proxy configuration should strip any client-supplied `Remote-*` headers before the auth check.
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Nginx Forward-Auth
|
||||||
|
|
||||||
|
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
|
||||||
|
|
||||||
|
This guide uses the [Nginx `auth_request`](http://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to ask Paskia whether each request is allowed before proxying it to your backend.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name app.example.com;
|
||||||
|
|
||||||
|
# 1. Proxy /auth/ to Paskia (HTTP + WebSocket).
|
||||||
|
location /auth/ {
|
||||||
|
proxy_pass http://localhost:4401;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. All other paths are protected.
|
||||||
|
location / {
|
||||||
|
auth_request /auth-internal;
|
||||||
|
|
||||||
|
# 3. Capture the Remote-* headers from the auth response and
|
||||||
|
# pass them to the backend request.
|
||||||
|
auth_request_set $remote_user $upstream_http_remote_user;
|
||||||
|
auth_request_set $remote_name $upstream_http_remote_name;
|
||||||
|
auth_request_set $remote_groups $upstream_http_remote_groups;
|
||||||
|
auth_request_set $remote_org $upstream_http_remote_org;
|
||||||
|
auth_request_set $remote_org_name $upstream_http_remote_org_name;
|
||||||
|
auth_request_set $remote_role $upstream_http_remote_role;
|
||||||
|
auth_request_set $remote_role_name $upstream_http_remote_role_name;
|
||||||
|
auth_request_set $remote_session_exp $upstream_http_remote_session_expires;
|
||||||
|
auth_request_set $remote_credential $upstream_http_remote_credential;
|
||||||
|
|
||||||
|
proxy_set_header Remote-User $remote_user;
|
||||||
|
proxy_set_header Remote-Name $remote_name;
|
||||||
|
proxy_set_header Remote-Groups $remote_groups;
|
||||||
|
proxy_set_header Remote-Org $remote_org;
|
||||||
|
proxy_set_header Remote-Org-Name $remote_org_name;
|
||||||
|
proxy_set_header Remote-Role $remote_role;
|
||||||
|
proxy_set_header Remote-Role-Name $remote_role_name;
|
||||||
|
proxy_set_header Remote-Session-Expires $remote_session_exp;
|
||||||
|
proxy_set_header Remote-Credential $remote_credential;
|
||||||
|
|
||||||
|
# 4. The proxy_set_header lines above override any client-supplied
|
||||||
|
# Remote-* headers, so the backend receives only the values from
|
||||||
|
# Paskia's auth response.
|
||||||
|
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. Internal endpoint used by auth_request.
|
||||||
|
location = /auth-internal {
|
||||||
|
internal;
|
||||||
|
proxy_pass http://localhost:4401/auth/api/forward?perm=myapp:login;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
proxy_set_header X-Forwarded-Method $request_method;
|
||||||
|
proxy_set_header X-Forwarded-Uri $request_uri;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
proxy_set_header Accept $http_accept;
|
||||||
|
# Drop hop-by-hop headers that must not reach the auth subrequest.
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_set_header Upgrade "";
|
||||||
|
proxy_set_header Transfer-Encoding "";
|
||||||
|
proxy_set_header Keep-Alive "";
|
||||||
|
proxy_set_header Proxy-Connection "";
|
||||||
|
proxy_set_header TE "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket upgrade map.
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## What the configuration does
|
||||||
|
|
||||||
|
1. **`/auth/`** is proxied straight to Paskia. The `Upgrade` and `Connection` headers are passed through so WebSocket endpoints such as `/auth/ws/authenticate` work.
|
||||||
|
2. **`/`** is protected by `auth_request /auth-internal`. Nginx makes an internal subrequest to that location before proxying the original request to the app.
|
||||||
|
3. **`auth_request_set`** captures each `Remote-*` header from the Paskia response. Nginx does not have wildcard capture, so every header must be listed explicitly. The captured values are then attached to the backend request with `proxy_set_header`.
|
||||||
|
4. The `proxy_set_header` lines override any `Remote-*` headers the client might have sent, so the backend can trust the headers that come from Paskia.
|
||||||
|
5. **`/auth-internal`** is the actual forward-auth call. It must:
|
||||||
|
- point to `/auth/api/forward`,
|
||||||
|
- not forward the request body (`proxy_pass_request_body off;`),
|
||||||
|
- pass `Host`, `Cookie`, `Accept`, `X-Forwarded-Method`, and `X-Forwarded-Uri`,
|
||||||
|
- strip hop-by-hop headers.
|
||||||
|
|
||||||
|
## Adjusting requirements
|
||||||
|
|
||||||
|
Change the query string on the `proxy_pass` line inside `/auth-internal` to require different permissions or recent authentication:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_pass http://localhost:4401/auth/api/forward?perm=myapp:login;
|
||||||
|
proxy_pass http://localhost:4401/auth/api/forward?perm=myapp:admin&max_age=5min;
|
||||||
|
proxy_pass http://localhost:4401/auth/api/forward?"";
|
||||||
|
```
|
||||||
|
|
||||||
|
The last form (`?""`) requires only authentication and no specific permission. See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) for details.
|
||||||
|
|
||||||
|
## Public paths
|
||||||
|
|
||||||
|
To leave some paths unprotected (for example `/.well-known/` or `/static/`), add `location` blocks before the protected `location /` block:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /.well-known/ {
|
||||||
|
root /var/www;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
root /var/www;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Nginx `auth_request` always makes the auth subrequest with the same HTTP method as the original request, but the body is suppressed by the configuration above. Paskia uses the `X-Forwarded-Method` and `X-Forwarded-Uri` headers for logging.
|
||||||
|
- The `auth_request_set` variables are empty when the auth request fails, so on 401/403 the backend is never contacted; Nginx returns Paskia's response directly.
|
||||||
|
- For HTTPS, add `listen 443 ssl;` and your certificate configuration as usual.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Traefik ForwardAuth
|
||||||
|
|
||||||
|
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
|
||||||
|
|
||||||
|
This guide uses Traefik's [ForwardAuth middleware](https://doc.traefik.io/traefik/reference/routing-configuration/http/middlewares/forwardauth/) to ask Paskia whether each request is allowed.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A typical dynamic (YAML) configuration looks like this:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
http:
|
||||||
|
routers:
|
||||||
|
app:
|
||||||
|
rule: "Host(`app.example.com`)"
|
||||||
|
service: app-backend
|
||||||
|
middlewares:
|
||||||
|
- paskia-auth
|
||||||
|
|
||||||
|
# Route /auth/ straight to Paskia, bypassing the auth middleware.
|
||||||
|
auth:
|
||||||
|
rule: "Host(`app.example.com`) && PathPrefix(`/auth/`)"
|
||||||
|
service: paskia
|
||||||
|
middlewares: []
|
||||||
|
|
||||||
|
middlewares:
|
||||||
|
paskia-auth:
|
||||||
|
forwardAuth:
|
||||||
|
address: "http://localhost:4401/auth/api/forward?perm=myapp:login"
|
||||||
|
# Forward every Remote-* header from the auth response to the backend.
|
||||||
|
authResponseHeadersRegex: "^Remote-"
|
||||||
|
# Explicitly pass the headers Paskia needs. If left empty, all headers
|
||||||
|
# are forwarded; being explicit avoids accidentally leaking hop-by-hop
|
||||||
|
# headers to the auth server.
|
||||||
|
authRequestHeaders:
|
||||||
|
- Host
|
||||||
|
- Cookie
|
||||||
|
- Accept
|
||||||
|
- X-Forwarded-Method
|
||||||
|
- X-Forwarded-Uri
|
||||||
|
- X-Forwarded-Host
|
||||||
|
- X-Forwarded-Proto
|
||||||
|
- X-Forwarded-For
|
||||||
|
|
||||||
|
services:
|
||||||
|
app-backend:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://localhost:3000"
|
||||||
|
|
||||||
|
paskia:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://localhost:4401"
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Traefik sends automatically
|
||||||
|
|
||||||
|
Traefik's ForwardAuth middleware sends the auth request to the configured `address` and includes the following headers derived from the original request:
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|---|---|
|
||||||
|
| `X-Forwarded-Method` | Original HTTP method |
|
||||||
|
| `X-Forwarded-Proto` | Original protocol (`http`/`https`) |
|
||||||
|
| `X-Forwarded-Host` | Original host |
|
||||||
|
| `X-Forwarded-Uri` | Original request URI (path and query) |
|
||||||
|
| `X-Forwarded-For` | Client IP address |
|
||||||
|
|
||||||
|
These are exactly the headers Paskia logs. You should still include them in `authRequestHeaders` if you set that list explicitly, to make sure they are not filtered out.
|
||||||
|
|
||||||
|
## Response headers
|
||||||
|
|
||||||
|
`authResponseHeadersRegex: "^Remote-"` tells Traefik to copy every response header starting with `Remote-` from Paskia's `204` response and add it to the request that is forwarded to your backend. It also strips any `Remote-*` headers that the client may have sent, so the backend can trust the values.
|
||||||
|
|
||||||
|
For stricter control, you can list the headers explicitly instead of using the regex:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
authResponseHeaders:
|
||||||
|
- Remote-User
|
||||||
|
- Remote-Name
|
||||||
|
- Remote-Groups
|
||||||
|
- Remote-Org
|
||||||
|
- Remote-Org-Name
|
||||||
|
- Remote-Role
|
||||||
|
- Remote-Role-Name
|
||||||
|
- Remote-Session-Expires
|
||||||
|
- Remote-Credential
|
||||||
|
```
|
||||||
|
|
||||||
|
## Proxying `/auth/` to Paskia
|
||||||
|
|
||||||
|
The `/auth/` router above forwards all authentication UI, API, and WebSocket traffic to Paskia. Because this router does **not** use the `paskia-auth` middleware, users can reach the login page and profile UI without being authenticated first. Traefik handles WebSocket upgrades automatically when the client requests them.
|
||||||
|
|
||||||
|
If you are using a dedicated authentication host instead of `/auth/`, create a separate router for `auth.example.com` pointing to the Paskia service and start Paskia with `--auth-host auth.example.com`.
|
||||||
|
|
||||||
|
## Adjusting requirements
|
||||||
|
|
||||||
|
Change the `address` query string to require different permissions or recent authentication:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
address: "http://localhost:4401/auth/api/forward?perm=myapp:login"
|
||||||
|
address: "http://localhost:4401/auth/api/forward?perm=myapp:admin&max_age=5min"
|
||||||
|
address: "http://localhost:4401/auth/api/forward"
|
||||||
|
```
|
||||||
|
|
||||||
|
The last form requires only authentication, no specific permission. See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md).
|
||||||
|
|
||||||
|
## Docker labels example
|
||||||
|
|
||||||
|
When using Traefik with Docker Compose, you can define the middleware with labels:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.myapp.rule=Host(`app.example.com`)"
|
||||||
|
- "traefik.http.routers.myapp.middlewares=paskia-auth"
|
||||||
|
- "traefik.http.middlewares.paskia-auth.forwardauth.address=http://localhost:4401/auth/api/forward?perm=myapp:login"
|
||||||
|
- "traefik.http.middlewares.paskia-auth.forwardauth.authResponseHeadersRegex=^Remote-"
|
||||||
|
- "traefik.http.middlewares.paskia-auth.forwardauth.authRequestHeaders=Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- By default ForwardAuth sends a request without the original body. If you need to forward the body for logging/validation, set `forwardBody: true` and a sensible `maxBodySize`, but for Paskia this is not required.
|
||||||
|
- Paskia does not set cookies on the forward-auth response; it only returns `Remote-*` headers. Use the JavaScript helpers from the [paskia](https://www.npmjs.com/package/paskia) package for session renewal in the frontend.
|
||||||
|
- For HTTPS, use `https://` in the `address` and configure TLS options (`tls.insecureSkipVerify: true` only for testing).
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Generated
+38
-26
@@ -10,7 +10,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
"@simplewebauthn/browser": "^13.1.2",
|
"@simplewebauthn/browser": "^13.1.2",
|
||||||
"@types/bun": "^1.3.3",
|
"@types/node": "*",
|
||||||
"c8": "^10.1.3"
|
"c8": "^10.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -92,11 +92,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.57.0",
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.57.0"
|
"playwright": "1.58.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -107,17 +109,11 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@simplewebauthn/browser": {
|
"node_modules/@simplewebauthn/browser": {
|
||||||
"version": "13.2.2",
|
"version": "13.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",
|
||||||
|
"integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/bun": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bun-types": "1.3.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/istanbul-lib-coverage": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||||
@@ -126,7 +122,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.10.1",
|
"version": "25.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
|
||||||
|
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -176,14 +174,6 @@
|
|||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bun-types": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/c8": {
|
"node_modules/c8": {
|
||||||
"version": "10.1.3",
|
"version": "10.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
|
||||||
@@ -412,6 +402,21 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-caller-file": {
|
"node_modules/get-caller-file": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
@@ -426,6 +431,7 @@
|
|||||||
"version": "10.5.0",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||||
|
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -674,11 +680,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.57.0",
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.57.0"
|
"playwright-core": "1.58.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -691,7 +699,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.57.0",
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -712,9 +722,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.3",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -894,6 +904,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|||||||
+8
-8
@@ -5,18 +5,18 @@
|
|||||||
"description": "E2E tests for Paskia using Playwright with Virtual Authenticator",
|
"description": "E2E tests for Paskia using Playwright with Virtual Authenticator",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bunx playwright test",
|
"test": "npx playwright test",
|
||||||
"test:headed": "bunx playwright test --headed",
|
"test:headed": "npx playwright test --headed",
|
||||||
"test:debug": "bunx playwright test --debug",
|
"test:debug": "npx playwright test --debug",
|
||||||
"test:ui": "bunx playwright test --ui",
|
"test:ui": "npx playwright test --ui",
|
||||||
"test:coverage": "COVERAGE=1 bunx playwright test",
|
"test:coverage": "COVERAGE=1 npx playwright test",
|
||||||
"report": "bunx playwright show-report",
|
"report": "npx playwright show-report",
|
||||||
"install:browsers": "bunx playwright install chromium"
|
"install:browsers": "npx playwright install chromium"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
"@simplewebauthn/browser": "^13.1.2",
|
"@simplewebauthn/browser": "^13.1.2",
|
||||||
"@types/bun": "^1.3.3",
|
"@types/node": "*",
|
||||||
"c8": "^10.1.3"
|
"c8": "^10.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { defineConfig, devices } from '@playwright/test'
|
|||||||
* Playwright configuration for Paskia E2E tests.
|
* Playwright configuration for Paskia E2E tests.
|
||||||
* Uses Chrome's Virtual Authenticator for automated passkey testing.
|
* Uses Chrome's Virtual Authenticator for automated passkey testing.
|
||||||
*
|
*
|
||||||
* Run with: bun run test
|
* Run with: npm test
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
@@ -97,14 +97,14 @@ test.describe('Passkey Authentication E2E', () => {
|
|||||||
// Verify registration result
|
// Verify registration result
|
||||||
expect(result.session_token).toBeDefined()
|
expect(result.session_token).toBeDefined()
|
||||||
expect(result.session_token).toHaveLength(16)
|
expect(result.session_token).toHaveLength(16)
|
||||||
expect(result.user_uuid).toBeDefined()
|
expect(result.user).toBeDefined()
|
||||||
expect(result.credential_uuid).toBeDefined()
|
expect(result.credential).toBeDefined()
|
||||||
expect(result.message).toContain('successfully')
|
expect(result.message).toContain('successfully')
|
||||||
|
|
||||||
// Store for subsequent tests
|
// Store for subsequent tests
|
||||||
sessionToken = result.session_token
|
sessionToken = result.session_token
|
||||||
userUuid = result.user_uuid
|
userUuid = result.user
|
||||||
credentialUuid = result.credential_uuid
|
credentialUuid = result.credential
|
||||||
|
|
||||||
// Save session token for other test groups to use
|
// Save session token for other test groups to use
|
||||||
saveSessionToken(sessionToken)
|
saveSessionToken(sessionToken)
|
||||||
@@ -138,9 +138,9 @@ test.describe('Passkey Authentication E2E', () => {
|
|||||||
const validation = await validateSession(page, baseUrl, sessionToken)
|
const validation = await validateSession(page, baseUrl, sessionToken)
|
||||||
|
|
||||||
expect(validation.valid).toBe(true)
|
expect(validation.valid).toBe(true)
|
||||||
expect(validation.user_uuid).toBe(userUuid)
|
expect(validation.ctx.user.uuid).toBe(userUuid)
|
||||||
|
|
||||||
console.log(`✓ Session validated for user: ${validation.user_uuid}`)
|
console.log(`✓ Session validated for user: ${validation.ctx.user.uuid}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should retrieve user info', async ({ page }) => {
|
test('should retrieve user info', async ({ page }) => {
|
||||||
@@ -148,10 +148,10 @@ test.describe('Passkey Authentication E2E', () => {
|
|||||||
|
|
||||||
const userInfo = await getUserInfo(page, baseUrl, sessionToken)
|
const userInfo = await getUserInfo(page, baseUrl, sessionToken)
|
||||||
|
|
||||||
expect(userInfo.user.user_uuid).toBe(userUuid)
|
expect(userInfo.user.uuid).toBe(userUuid)
|
||||||
expect(userInfo.user.user_name).toBe('Admin User')
|
expect(userInfo.user.display_name).toBe('Admin User')
|
||||||
expect(userInfo.credentials).toBeDefined()
|
expect(userInfo.credentials).toBeDefined()
|
||||||
expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1)
|
expect(Object.keys(userInfo.credentials).length).toBeGreaterThanOrEqual(1)
|
||||||
|
|
||||||
// Navigate to profile and take screenshot
|
// Navigate to profile and take screenshot
|
||||||
const cookieName = getSessionCookieName()
|
const cookieName = getSessionCookieName()
|
||||||
@@ -169,8 +169,8 @@ test.describe('Passkey Authentication E2E', () => {
|
|||||||
await page.screenshot({ path: 'test-results/profile-view.png' })
|
await page.screenshot({ path: 'test-results/profile-view.png' })
|
||||||
console.log('✓ Screenshot saved: test-results/profile-view.png')
|
console.log('✓ Screenshot saved: test-results/profile-view.png')
|
||||||
|
|
||||||
console.log(`✓ User info retrieved: ${userInfo.user.user_name}`)
|
console.log(`✓ User info retrieved: ${userInfo.user.display_name}`)
|
||||||
console.log(`✓ Credentials count: ${userInfo.credentials.length}`)
|
console.log(`✓ Credentials count: ${Object.keys(userInfo.credentials).length}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => {
|
test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => {
|
||||||
@@ -190,7 +190,7 @@ test.describe('Passkey Authentication E2E', () => {
|
|||||||
displayName: 'Admin User (test device)'
|
displayName: 'Admin User (test device)'
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(`✓ Added test credential: ${regResult.credential_uuid}`)
|
console.log(`✓ Added test credential: ${regResult.credential}`)
|
||||||
|
|
||||||
// Now logout and authenticate with the fresh credential
|
// Now logout and authenticate with the fresh credential
|
||||||
await logout(page, baseUrl, regResult.session_token)
|
await logout(page, baseUrl, regResult.session_token)
|
||||||
@@ -201,7 +201,7 @@ test.describe('Passkey Authentication E2E', () => {
|
|||||||
|
|
||||||
expect(result.session_token).toBeDefined()
|
expect(result.session_token).toBeDefined()
|
||||||
expect(result.session_token).toHaveLength(16)
|
expect(result.session_token).toHaveLength(16)
|
||||||
expect(result.user_uuid).toBe(userUuid)
|
expect(result.user).toBe(userUuid)
|
||||||
|
|
||||||
// Update session token for subsequent tests
|
// Update session token for subsequent tests
|
||||||
sessionToken = result.session_token
|
sessionToken = result.session_token
|
||||||
@@ -209,7 +209,7 @@ test.describe('Passkey Authentication E2E', () => {
|
|||||||
// Save session token for other test groups to use
|
// Save session token for other test groups to use
|
||||||
saveSessionToken(sessionToken)
|
saveSessionToken(sessionToken)
|
||||||
|
|
||||||
console.log(`✓ Authenticated as user: ${result.user_uuid}`)
|
console.log(`✓ Authenticated as user: ${result.user}`)
|
||||||
console.log(`✓ New session token: ${sessionToken.substring(0, 4)}...`)
|
console.log(`✓ New session token: ${sessionToken.substring(0, 4)}...`)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ test.describe('Passkey Authentication E2E', () => {
|
|||||||
const validation = await validateSession(page, baseUrl, sessionToken)
|
const validation = await validateSession(page, baseUrl, sessionToken)
|
||||||
|
|
||||||
expect(validation.valid).toBe(true)
|
expect(validation.valid).toBe(true)
|
||||||
expect(validation.user_uuid).toBe(userUuid)
|
expect(validation.ctx.user.uuid).toBe(userUuid)
|
||||||
|
|
||||||
console.log(`✓ New session validated`)
|
console.log(`✓ New session validated`)
|
||||||
})
|
})
|
||||||
@@ -291,8 +291,8 @@ test.describe('Device Addition Dialog', () => {
|
|||||||
// Wait for the profile view to load
|
// Wait for the profile view to load
|
||||||
await page.waitForSelector('[data-view="profile"]', { timeout: 5000 })
|
await page.waitForSelector('[data-view="profile"]', { timeout: 5000 })
|
||||||
|
|
||||||
// Click the "Add Another Device" button
|
// Click the "Another Device" button
|
||||||
const addDeviceButton = page.getByRole('button', { name: 'Add Another Device' })
|
const addDeviceButton = page.getByRole('button', { name: 'Another Device' })
|
||||||
await expect(addDeviceButton).toBeVisible()
|
await expect(addDeviceButton).toBeVisible()
|
||||||
await addDeviceButton.click()
|
await addDeviceButton.click()
|
||||||
|
|
||||||
@@ -301,7 +301,7 @@ test.describe('Device Addition Dialog', () => {
|
|||||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||||
|
|
||||||
// Verify dialog contains expected elements
|
// Verify dialog contains expected elements
|
||||||
await expect(dialog.locator('h2')).toContainText('Device Registration Link')
|
await expect(dialog.locator('h2')).toContainText('Add Another Device')
|
||||||
|
|
||||||
// Wait for QR code to be generated (canvas should have content)
|
// Wait for QR code to be generated (canvas should have content)
|
||||||
const qrCanvas = dialog.locator('.qr-code')
|
const qrCanvas = dialog.locator('.qr-code')
|
||||||
@@ -318,16 +318,16 @@ test.describe('Device Addition Dialog', () => {
|
|||||||
expect(linkHref).toContain('http://localhost:4404/auth/')
|
expect(linkHref).toContain('http://localhost:4404/auth/')
|
||||||
console.log(`✓ Device link displayed: ${linkText} (href: ${linkHref})`)
|
console.log(`✓ Device link displayed: ${linkText} (href: ${linkHref})`)
|
||||||
|
|
||||||
// Verify expiration warning is shown
|
// Verify help text is shown
|
||||||
await expect(dialog.locator('.reg-help')).toContainText('Expires')
|
await expect(dialog.locator('.reg-help')).toContainText('Scan this QR code')
|
||||||
|
|
||||||
// Take screenshot of the dialog
|
// Take screenshot of the dialog
|
||||||
await dialog.screenshot({ path: 'test-results/device-addition-dialog.png' })
|
await dialog.screenshot({ path: 'test-results/device-addition-dialog.png' })
|
||||||
console.log(`✓ Screenshot saved: test-results/device-addition-dialog.png`)
|
console.log(`✓ Screenshot saved: test-results/device-addition-dialog.png`)
|
||||||
|
|
||||||
// Verify Copy Link button exists
|
// Verify the QR link element is clickable (copy functionality is built into clicking it)
|
||||||
const copyButton = dialog.getByRole('button', { name: 'Copy Link' })
|
const qrLink = dialog.locator('a.qr-link')
|
||||||
await expect(copyButton).toBeVisible()
|
await expect(qrLink).toBeVisible()
|
||||||
|
|
||||||
// Close the dialog (use the text button, not the icon button)
|
// Close the dialog (use the text button, not the icon button)
|
||||||
const closeButton = dialog.locator('button.btn-secondary', { hasText: 'Close' })
|
const closeButton = dialog.locator('button.btn-secondary', { hasText: 'Close' })
|
||||||
@@ -357,12 +357,12 @@ test.describe('Device Addition Dialog', () => {
|
|||||||
await page.waitForSelector('[data-view="profile"]', { timeout: 5000 })
|
await page.waitForSelector('[data-view="profile"]', { timeout: 5000 })
|
||||||
|
|
||||||
// Open the dialog
|
// Open the dialog
|
||||||
await page.getByRole('button', { name: 'Add Another Device' }).click()
|
await page.getByRole('button', { name: 'Another Device' }).click()
|
||||||
const dialog = page.locator('.device-dialog')
|
const dialog = page.locator('.device-dialog')
|
||||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||||
|
|
||||||
// Extract the reset token from the displayed URL
|
// Extract the reset token from the displayed URL
|
||||||
const linkText = dialog.locator('.qr-link p')
|
const linkText = dialog.locator('.qr-link .link-text')
|
||||||
const linkContent = await linkText.textContent()
|
const linkContent = await linkText.textContent()
|
||||||
|
|
||||||
// URL format: localhost/auth/word1.word2.word3.word4.word5
|
// URL format: localhost/auth/word1.word2.word3.word4.word5
|
||||||
@@ -405,7 +405,7 @@ test.describe('Device Addition Dialog', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test.describe('ProfileView - Add New Passkey', () => {
|
test.describe('ProfileView - Register New', () => {
|
||||||
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
|
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
|
||||||
|
|
||||||
test('should show credentials list in profile', async ({ page }) => {
|
test('should show credentials list in profile', async ({ page }) => {
|
||||||
@@ -427,7 +427,7 @@ test.describe('ProfileView - Add New Passkey', () => {
|
|||||||
console.log(`✓ Profile shows ${credentialItems} credential(s) in list`)
|
console.log(`✓ Profile shows ${credentialItems} credential(s) in list`)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should add a new passkey using Add New Passkey button', async ({ page }) => {
|
test('should add a new passkey using Register New button', async ({ page }) => {
|
||||||
const sessionToken = getSavedSessionToken()
|
const sessionToken = getSavedSessionToken()
|
||||||
test.skip(!sessionToken, 'Requires saved session token')
|
test.skip(!sessionToken, 'Requires saved session token')
|
||||||
|
|
||||||
@@ -444,8 +444,8 @@ test.describe('ProfileView - Add New Passkey', () => {
|
|||||||
const initialCredentialCount = await page.locator('.credential-item').count()
|
const initialCredentialCount = await page.locator('.credential-item').count()
|
||||||
console.log(`Initial credential count: ${initialCredentialCount}`)
|
console.log(`Initial credential count: ${initialCredentialCount}`)
|
||||||
|
|
||||||
// Click "Add New Passkey" button
|
// Click "Register New" button
|
||||||
const addPasskeyBtn = page.locator('button:has-text("Add New Passkey")')
|
const addPasskeyBtn = page.locator('button:has-text("Register New")')
|
||||||
await expect(addPasskeyBtn).toBeVisible()
|
await expect(addPasskeyBtn).toBeVisible()
|
||||||
await addPasskeyBtn.click()
|
await addPasskeyBtn.click()
|
||||||
|
|
||||||
@@ -490,7 +490,7 @@ test.describe('ProfileView - Add New Passkey', () => {
|
|||||||
|
|
||||||
// Try to add a passkey - with excludeCredentials the authenticator should
|
// Try to add a passkey - with excludeCredentials the authenticator should
|
||||||
// prevent re-registration of the same credential
|
// prevent re-registration of the same credential
|
||||||
const addPasskeyBtn = page.locator('button:has-text("Add New Passkey")')
|
const addPasskeyBtn = page.locator('button:has-text("Register New")')
|
||||||
await expect(addPasskeyBtn).toBeVisible()
|
await expect(addPasskeyBtn).toBeVisible()
|
||||||
await addPasskeyBtn.click()
|
await addPasskeyBtn.click()
|
||||||
|
|
||||||
@@ -541,8 +541,8 @@ test.describe('ProfileView - Multi-Authenticator', () => {
|
|||||||
await page.waitForSelector('.credential-list', { timeout: 10000 })
|
await page.waitForSelector('.credential-list', { timeout: 10000 })
|
||||||
const initialCredentialCount = await page.locator('.credential-item').count()
|
const initialCredentialCount = await page.locator('.credential-item').count()
|
||||||
|
|
||||||
// Click "Add New Passkey" button
|
// Click "Register New" button
|
||||||
const addPasskeyBtn = page.locator('button:has-text("Add New Passkey")')
|
const addPasskeyBtn = page.locator('button:has-text("Register New")')
|
||||||
await expect(addPasskeyBtn).toBeVisible()
|
await expect(addPasskeyBtn).toBeVisible()
|
||||||
await addPasskeyBtn.click()
|
await addPasskeyBtn.click()
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ import {
|
|||||||
logout,
|
logout,
|
||||||
} from './fixtures/passkey-helpers'
|
} from './fixtures/passkey-helpers'
|
||||||
import type { Page, Frame } from '@playwright/test'
|
import type { Page, Frame } from '@playwright/test'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E2E tests for API mode authentication flows.
|
* E2E tests for API mode authentication flows.
|
||||||
@@ -55,8 +61,18 @@ async function clearSessionCookie(page: Page): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* Set up the test page using the examples page directly.
|
* Set up the test page using the examples page directly.
|
||||||
* The examples page already has iframe handling - we just add a Promise wrapper.
|
* The examples page already has iframe handling - we just add a Promise wrapper.
|
||||||
|
* We route the paskia-js module request to serve from the local dist.
|
||||||
*/
|
*/
|
||||||
async function setupTestHarness(page: Page): Promise<void> {
|
async function setupTestHarness(page: Page): Promise<void> {
|
||||||
|
// Serve paskia.js from the local filesystem since the server doesn't serve /paskia-js/
|
||||||
|
const paskiaJsPath = join(__dirname, '..', '..', 'paskia-js', 'dist', 'paskia.js')
|
||||||
|
await page.route('**/paskia-js/dist/paskia.js', async route => {
|
||||||
|
const body = readFileSync(paskiaJsPath, 'utf-8')
|
||||||
|
await route.fulfill({
|
||||||
|
body,
|
||||||
|
contentType: 'application/javascript',
|
||||||
|
})
|
||||||
|
})
|
||||||
// Navigate to the examples page which already has the auth iframe handling
|
// Navigate to the examples page which already has the auth iframe handling
|
||||||
await page.goto(`${baseUrl}/auth/examples/`)
|
await page.goto(`${baseUrl}/auth/examples/`)
|
||||||
}
|
}
|
||||||
@@ -84,7 +100,7 @@ async function makeApiCall(page: Page, url: string, method = 'GET'): Promise<{ s
|
|||||||
// Wait a tick for the page's handler to retry, then make our own call
|
// Wait a tick for the page's handler to retry, then make our own call
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { method, credentials: 'include' });
|
const response = await fetch(url, { method });
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
resolve({ status: 204 });
|
resolve({ status: 204 });
|
||||||
} else if (response.ok) {
|
} else if (response.ok) {
|
||||||
@@ -111,7 +127,7 @@ async function makeApiCall(page: Page, url: string, method = 'GET'): Promise<{ s
|
|||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
if (resolved) return;
|
if (resolved) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { method, credentials: 'include' });
|
const response = await fetch(url, { method });
|
||||||
// Only resolve if this is a success or non-auth error
|
// Only resolve if this is a success or non-auth error
|
||||||
if (response.status !== 401 && response.status !== 403) {
|
if (response.status !== 401 && response.status !== 403) {
|
||||||
if (resolved) return;
|
if (resolved) return;
|
||||||
@@ -143,8 +159,8 @@ async function makeApiCall(page: Page, url: string, method = 'GET'): Promise<{ s
|
|||||||
* Wait for auth iframe to appear and return a reference to it.
|
* Wait for auth iframe to appear and return a reference to it.
|
||||||
*/
|
*/
|
||||||
async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> {
|
async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> {
|
||||||
await page.waitForSelector('#auth-iframe', { timeout })
|
await page.waitForSelector('#paskia-iframe', { timeout })
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
// Wait for iframe content to load
|
// Wait for iframe content to load
|
||||||
await iframe.locator('.view-root').waitFor({ timeout })
|
await iframe.locator('.view-root').waitFor({ timeout })
|
||||||
return page.frame({ url: /\/auth\/restricted\// })!
|
return page.frame({ url: /\/auth\/restricted\// })!
|
||||||
@@ -154,14 +170,14 @@ async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> {
|
|||||||
* Wait for auth iframe to disappear.
|
* Wait for auth iframe to disappear.
|
||||||
*/
|
*/
|
||||||
async function waitForAuthIframeHidden(page: Page, timeout = 5000): Promise<void> {
|
async function waitForAuthIframeHidden(page: Page, timeout = 5000): Promise<void> {
|
||||||
await page.waitForSelector('#auth-iframe', { state: 'detached', timeout })
|
await page.waitForSelector('#paskia-iframe', { state: 'detached', timeout })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Click Back button in auth iframe.
|
* Click Back button in auth iframe.
|
||||||
*/
|
*/
|
||||||
async function clickBackInIframe(page: Page): Promise<void> {
|
async function clickBackInIframe(page: Page): Promise<void> {
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
await iframe.getByRole('button', { name: 'Back' }).click()
|
await iframe.getByRole('button', { name: 'Back' }).click()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +185,7 @@ async function clickBackInIframe(page: Page): Promise<void> {
|
|||||||
* Click Login button in auth iframe.
|
* Click Login button in auth iframe.
|
||||||
*/
|
*/
|
||||||
async function clickLoginInIframe(page: Page): Promise<void> {
|
async function clickLoginInIframe(page: Page): Promise<void> {
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
await iframe.getByRole('button', { name: 'Login' }).click()
|
await iframe.getByRole('button', { name: 'Login' }).click()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +193,7 @@ async function clickLoginInIframe(page: Page): Promise<void> {
|
|||||||
* Click Verify button in auth iframe (for reauth mode).
|
* Click Verify button in auth iframe (for reauth mode).
|
||||||
*/
|
*/
|
||||||
async function clickVerifyInIframe(page: Page): Promise<void> {
|
async function clickVerifyInIframe(page: Page): Promise<void> {
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
await iframe.getByRole('button', { name: 'Verify' }).click()
|
await iframe.getByRole('button', { name: 'Verify' }).click()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +201,7 @@ async function clickVerifyInIframe(page: Page): Promise<void> {
|
|||||||
* Click Logout button in auth iframe (for forbidden mode).
|
* Click Logout button in auth iframe (for forbidden mode).
|
||||||
*/
|
*/
|
||||||
async function clickLogoutInIframe(page: Page): Promise<void> {
|
async function clickLogoutInIframe(page: Page): Promise<void> {
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
await iframe.getByRole('button', { name: 'Logout' }).click()
|
await iframe.getByRole('button', { name: 'Logout' }).click()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,11 +216,11 @@ test.describe('API Mode - 401 Login Flow', () => {
|
|||||||
await clearSessionCookie(page)
|
await clearSessionCookie(page)
|
||||||
|
|
||||||
// Make API call that triggers 401 (don't await - it blocks until iframe resolves)
|
// Make API call that triggers 401 (don't await - it blocks until iframe resolves)
|
||||||
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'POST').catch(e => e)
|
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'GET').catch(e => e)
|
||||||
console.log('✓ Auth iframe appeared on 401')
|
console.log('✓ Auth iframe appeared on 401')
|
||||||
|
|
||||||
// Verify it's in login mode (not reauth)
|
// Verify it's in login mode (not reauth)
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
await expect(iframe.locator('h1')).toContainText('🔐')
|
await expect(iframe.locator('h1')).toContainText('🔐')
|
||||||
await expect(iframe.getByRole('button', { name: 'Login' })).toBeVisible()
|
await expect(iframe.getByRole('button', { name: 'Login' })).toBeVisible()
|
||||||
|
|
||||||
@@ -242,7 +258,7 @@ test.describe('API Mode - 401 Login Flow', () => {
|
|||||||
resetToken: deviceToken,
|
resetToken: deviceToken,
|
||||||
displayName: 'API Test Device',
|
displayName: 'API Test Device',
|
||||||
})
|
})
|
||||||
console.log(`✓ Registered credential: ${regResult.credential_uuid}`)
|
console.log(`✓ Registered credential: ${regResult.credential}`)
|
||||||
|
|
||||||
// Logout to clear session (but keep the passkey in virtual authenticator)
|
// Logout to clear session (but keep the passkey in virtual authenticator)
|
||||||
await logout(page, baseUrl, regResult.session_token)
|
await logout(page, baseUrl, regResult.session_token)
|
||||||
@@ -252,7 +268,7 @@ test.describe('API Mode - 401 Login Flow', () => {
|
|||||||
await setupTestHarness(page)
|
await setupTestHarness(page)
|
||||||
|
|
||||||
// Make API call that triggers 401
|
// Make API call that triggers 401
|
||||||
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'POST')
|
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'GET')
|
||||||
|
|
||||||
// Wait for auth iframe to appear
|
// Wait for auth iframe to appear
|
||||||
await waitForAuthIframe(page)
|
await waitForAuthIframe(page)
|
||||||
@@ -314,7 +330,7 @@ test.describe('API Mode - 401 Reauth Flow', () => {
|
|||||||
console.log('✓ Reauth iframe appeared (session older than max_age)')
|
console.log('✓ Reauth iframe appeared (session older than max_age)')
|
||||||
|
|
||||||
// Verify it's in reauth mode
|
// Verify it's in reauth mode
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
await expect(iframe.locator('h1')).toContainText('Additional Authentication')
|
await expect(iframe.locator('h1')).toContainText('Additional Authentication')
|
||||||
await expect(iframe.getByRole('button', { name: 'Verify' })).toBeVisible()
|
await expect(iframe.getByRole('button', { name: 'Verify' })).toBeVisible()
|
||||||
|
|
||||||
@@ -362,7 +378,7 @@ test.describe('API Mode - 401 Reauth Flow', () => {
|
|||||||
await waitForAuthIframe(page)
|
await waitForAuthIframe(page)
|
||||||
console.log('✓ Reauth iframe appeared')
|
console.log('✓ Reauth iframe appeared')
|
||||||
|
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
await expect(iframe.locator('h1')).toContainText('Additional Authentication')
|
await expect(iframe.locator('h1')).toContainText('Additional Authentication')
|
||||||
|
|
||||||
// Click Verify - virtual authenticator handles passkey
|
// Click Verify - virtual authenticator handles passkey
|
||||||
@@ -394,7 +410,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
|
|||||||
const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e)
|
const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e)
|
||||||
|
|
||||||
// Check if auth iframe appeared
|
// Check if auth iframe appeared
|
||||||
const iframeAppeared = await page.waitForSelector('#auth-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
|
const iframeAppeared = await page.waitForSelector('#paskia-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
|
||||||
|
|
||||||
if (!iframeAppeared) {
|
if (!iframeAppeared) {
|
||||||
// User might already have admin permission
|
// User might already have admin permission
|
||||||
@@ -410,7 +426,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
|
|||||||
|
|
||||||
// Wait for view to stabilize and check mode
|
// Wait for view to stabilize and check mode
|
||||||
await page.waitForTimeout(500)
|
await page.waitForTimeout(500)
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
const headingText = await iframe.locator('h1').textContent()
|
const headingText = await iframe.locator('h1').textContent()
|
||||||
console.log(` Heading: ${headingText}`)
|
console.log(` Heading: ${headingText}`)
|
||||||
|
|
||||||
@@ -459,7 +475,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
|
|||||||
const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e)
|
const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e)
|
||||||
|
|
||||||
// Check if auth iframe appeared
|
// Check if auth iframe appeared
|
||||||
const iframeAppeared = await page.waitForSelector('#auth-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
|
const iframeAppeared = await page.waitForSelector('#paskia-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
|
||||||
|
|
||||||
if (!iframeAppeared) {
|
if (!iframeAppeared) {
|
||||||
const result = await apiCallPromise
|
const result = await apiCallPromise
|
||||||
@@ -470,7 +486,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await waitForAuthIframe(page)
|
await waitForAuthIframe(page)
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
await page.waitForTimeout(500)
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
const headingText = await iframe.locator('h1').textContent()
|
const headingText = await iframe.locator('h1').textContent()
|
||||||
@@ -534,7 +550,7 @@ test.describe('API Mode - Direct API Response Format', () => {
|
|||||||
expect(data.auth).toBeDefined()
|
expect(data.auth).toBeDefined()
|
||||||
expect(data.auth.iframe).toBeDefined()
|
expect(data.auth.iframe).toBeDefined()
|
||||||
expect(data.auth.mode).toBe('login')
|
expect(data.auth.mode).toBe('login')
|
||||||
expect(data.auth.iframe).toContain('/auth/restricted/')
|
expect(data.auth.iframe).toContain('/auth/restricted/iframe')
|
||||||
|
|
||||||
console.log(`✓ 401 response includes auth.iframe: ${data.auth.iframe}`)
|
console.log(`✓ 401 response includes auth.iframe: ${data.auth.iframe}`)
|
||||||
})
|
})
|
||||||
|
|||||||
+100
-16
@@ -12,17 +12,51 @@ const stateFile = join(__dirname, '..', '..', 'test-data', 'test-state.json')
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface RegistrationResult {
|
export interface RegistrationResult {
|
||||||
user_uuid: string
|
user: string
|
||||||
credential_uuid: string
|
credential: string
|
||||||
session_token: string
|
session_token: string
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthenticationResult {
|
export interface AuthenticationResult {
|
||||||
user_uuid: string
|
user: string
|
||||||
session_token: string
|
session_token: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionContext {
|
||||||
|
user: { uuid: string; display_name: string }
|
||||||
|
org: { uuid: string; display_name: string }
|
||||||
|
role: { uuid: string; display_name: string }
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
ctx: SessionContext
|
||||||
|
created_at: string
|
||||||
|
last_seen: string
|
||||||
|
visits: number
|
||||||
|
credentials: Array<{
|
||||||
|
credential: string
|
||||||
|
aaguid: string
|
||||||
|
created_at: string
|
||||||
|
last_used: string | null
|
||||||
|
last_verified: string | null
|
||||||
|
sign_count: number
|
||||||
|
is_current_session: boolean
|
||||||
|
}>
|
||||||
|
aaguid_info: Record<string, { name: string; icon?: string; icon_dark?: string }>
|
||||||
|
sessions: Array<{
|
||||||
|
id: string
|
||||||
|
credential: string
|
||||||
|
host: string
|
||||||
|
ip: string
|
||||||
|
user_agent: string
|
||||||
|
last_renewed: string
|
||||||
|
is_current: boolean
|
||||||
|
is_current_host: boolean
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the bootstrap reset token from the test state file.
|
* Get the bootstrap reset token from the test state file.
|
||||||
*/
|
*/
|
||||||
@@ -159,7 +193,8 @@ export async function registerPasskey(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
options: { resetToken?: string; displayName?: string } = {}
|
options: { resetToken?: string; displayName?: string } = {}
|
||||||
): Promise<RegistrationResult> {
|
): Promise<RegistrationResult> {
|
||||||
return await page.evaluate(async ({ baseUrl, resetToken, displayName }) => {
|
// Step 1: Do WebSocket registration + exchange code in browser context
|
||||||
|
const wsResult = await page.evaluate(async ({ baseUrl, resetToken, displayName }) => {
|
||||||
// Build WebSocket URL with query parameters
|
// Build WebSocket URL with query parameters
|
||||||
let wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/register`
|
let wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/register`
|
||||||
const params: string[] = []
|
const params: string[] = []
|
||||||
@@ -169,6 +204,7 @@ export async function registerPasskey(
|
|||||||
|
|
||||||
return new Promise<any>((resolve, reject) => {
|
return new Promise<any>((resolve, reject) => {
|
||||||
const ws = new WebSocket(wsUrl)
|
const ws = new WebSocket(wsUrl)
|
||||||
|
let done = false
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
console.log('WebSocket connected for registration')
|
console.log('WebSocket connected for registration')
|
||||||
@@ -179,15 +215,31 @@ export async function registerPasskey(
|
|||||||
|
|
||||||
// Check for error response
|
// Check for error response
|
||||||
if (data.detail) {
|
if (data.detail) {
|
||||||
|
done = true
|
||||||
ws.close()
|
ws.close()
|
||||||
reject(new Error(data.detail))
|
reject(new Error(data.detail))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is the final success response
|
// Check if this is the final success response (exchange_code flow)
|
||||||
if (data.session_token) {
|
if (data.exchange_code) {
|
||||||
|
done = true
|
||||||
ws.close()
|
ws.close()
|
||||||
resolve(data)
|
// Exchange the code for a session cookie
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${baseUrl}/auth/api/set-session`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${data.exchange_code}` },
|
||||||
|
})
|
||||||
|
if (!resp.ok) throw new Error(`Exchange failed: ${resp.status}`)
|
||||||
|
resolve({
|
||||||
|
user: data.user,
|
||||||
|
credential: data.credential,
|
||||||
|
message: data.message || 'Registration successful',
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
reject(new Error(`Code exchange failed: ${err.message}`))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,12 +311,21 @@ export async function registerPasskey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
if (!event.wasClean && event.code !== 1000) {
|
if (!done && !event.wasClean && event.code !== 1000) {
|
||||||
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, { baseUrl, resetToken: options.resetToken, displayName: options.displayName })
|
}, { baseUrl, resetToken: options.resetToken, displayName: options.displayName })
|
||||||
|
|
||||||
|
// Step 2: Extract the session token from the cookie set by the exchange
|
||||||
|
const cookies = await page.context().cookies()
|
||||||
|
const cookieName = getSessionCookieName()
|
||||||
|
const sessionCookie = cookies.find(c => c.name === cookieName)
|
||||||
|
return {
|
||||||
|
...wsResult,
|
||||||
|
session_token: sessionCookie?.value || '',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -275,11 +336,13 @@ export async function authenticatePasskey(
|
|||||||
page: Page,
|
page: Page,
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
): Promise<AuthenticationResult> {
|
): Promise<AuthenticationResult> {
|
||||||
return await page.evaluate(async ({ baseUrl }) => {
|
// Step 1: Do WebSocket authentication + exchange code in browser context
|
||||||
|
const wsResult = await page.evaluate(async ({ baseUrl }) => {
|
||||||
const wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/authenticate`
|
const wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/authenticate`
|
||||||
|
|
||||||
return new Promise<any>((resolve, reject) => {
|
return new Promise<any>((resolve, reject) => {
|
||||||
const ws = new WebSocket(wsUrl)
|
const ws = new WebSocket(wsUrl)
|
||||||
|
let done = false
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
console.log('WebSocket connected for authentication')
|
console.log('WebSocket connected for authentication')
|
||||||
@@ -290,15 +353,27 @@ export async function authenticatePasskey(
|
|||||||
|
|
||||||
// Check for error response
|
// Check for error response
|
||||||
if (data.detail) {
|
if (data.detail) {
|
||||||
|
done = true
|
||||||
ws.close()
|
ws.close()
|
||||||
reject(new Error(data.detail))
|
reject(new Error(data.detail))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is the final success response
|
// Check if this is the final success response (exchange_code flow)
|
||||||
if (data.session_token) {
|
if (data.exchange_code) {
|
||||||
|
done = true
|
||||||
ws.close()
|
ws.close()
|
||||||
resolve(data)
|
// Exchange the code for a session cookie
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${baseUrl}/auth/api/set-session`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${data.exchange_code}` },
|
||||||
|
})
|
||||||
|
if (!resp.ok) throw new Error(`Exchange failed: ${resp.status}`)
|
||||||
|
resolve({ user: data.user })
|
||||||
|
} catch (err: any) {
|
||||||
|
reject(new Error(`Code exchange failed: ${err.message}`))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,12 +436,21 @@ export async function authenticatePasskey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
if (!event.wasClean && event.code !== 1000) {
|
if (!done && !event.wasClean && event.code !== 1000) {
|
||||||
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, { baseUrl })
|
}, { baseUrl })
|
||||||
|
|
||||||
|
// Step 2: Extract the session token from the cookie set by the exchange
|
||||||
|
const cookies = await page.context().cookies()
|
||||||
|
const cookieName = getSessionCookieName()
|
||||||
|
const sessionCookie = cookies.find(c => c.name === cookieName)
|
||||||
|
return {
|
||||||
|
...wsResult,
|
||||||
|
session_token: sessionCookie?.value || '',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -376,7 +460,7 @@ export async function validateSession(
|
|||||||
page: Page,
|
page: Page,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
sessionToken: string
|
sessionToken: string
|
||||||
): Promise<{ valid: boolean; user_uuid: string; renewed: boolean }> {
|
): Promise<{ valid: boolean; ctx: SessionContext; renewed: boolean }> {
|
||||||
const cookieName = getSessionCookieName()
|
const cookieName = getSessionCookieName()
|
||||||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -393,9 +477,9 @@ export async function getUserInfo(
|
|||||||
page: Page,
|
page: Page,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
sessionToken: string
|
sessionToken: string
|
||||||
): Promise<any> {
|
): Promise<UserInfo> {
|
||||||
const cookieName = getSessionCookieName()
|
const cookieName = getSessionCookieName()
|
||||||
const response = await page.request.post(`${baseUrl}/auth/api/user-info`, {
|
const response = await page.request.get(`${baseUrl}/auth/api/user-info`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cookie': `${cookieName}=${sessionToken}`,
|
'Cookie': `${cookieName}=${sessionToken}`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { spawn } from 'child_process'
|
import { execSync, spawn } from 'child_process'
|
||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
@@ -31,6 +31,11 @@ export default async function globalSetup() {
|
|||||||
mkdirSync(testDataDir, { recursive: true })
|
mkdirSync(testDataDir, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build the package first
|
||||||
|
console.log(' Building package with uv build...')
|
||||||
|
execSync('uv build', { cwd: projectRoot, stdio: 'inherit' })
|
||||||
|
console.log(' ✅ Build complete\n')
|
||||||
|
|
||||||
console.log(' Starting server with in-memory database...')
|
console.log(' Starting server with in-memory database...')
|
||||||
if (COLLECT_COVERAGE) {
|
if (COLLECT_COVERAGE) {
|
||||||
console.log(' 📊 Coverage collection enabled for Python backend')
|
console.log(' 📊 Coverage collection enabled for Python backend')
|
||||||
@@ -42,21 +47,28 @@ export default async function globalSetup() {
|
|||||||
const serverArgs = COLLECT_COVERAGE
|
const serverArgs = COLLECT_COVERAGE
|
||||||
? [
|
? [
|
||||||
'run', 'coverage', 'run', '--parallel-mode',
|
'run', 'coverage', 'run', '--parallel-mode',
|
||||||
'-m', 'paskia.fastapi', 'serve', 'localhost:4404',
|
'-m', 'paskia', '-l', 'localhost:4404',
|
||||||
'--rp-id', 'localhost'
|
'--rp-id', 'localhost'
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
'run', 'paskia', 'serve', 'localhost:4404',
|
'run', 'paskia', '-l', 'localhost:4404',
|
||||||
'--rp-id', 'localhost'
|
'--rp-id', 'localhost'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Use a fresh database file for tests
|
||||||
|
const testDbFile = join(testDataDir, 'test.paskiadb')
|
||||||
|
|
||||||
|
if (existsSync(testDbFile)) {
|
||||||
|
console.log(' Removing stale test database...')
|
||||||
|
rmSync(testDbFile, { force: true, recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
// Start the server using Node's spawn
|
// Start the server using Node's spawn
|
||||||
// Use in-memory SQLite for faster tests
|
|
||||||
const serverProcess = spawn('uv', serverArgs, {
|
const serverProcess = spawn('uv', serverArgs, {
|
||||||
cwd: projectRoot,
|
cwd: projectRoot,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
PASKIA_DB: 'sqlite+aiosqlite:///:memory:',
|
PASKIA_DB: testDbFile,
|
||||||
COVERAGE_FILE: join(projectRoot, '.coverage'),
|
COVERAGE_FILE: join(projectRoot, '.coverage'),
|
||||||
},
|
},
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
|||||||
@@ -59,18 +59,11 @@ export default async function globalTeardown() {
|
|||||||
rmSync(stateFile, { force: true })
|
rmSync(stateFile, { force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally clean up test database (keep it for debugging by default)
|
// Clean up test database
|
||||||
if (process.env.CLEANUP_TEST_DB === 'true') {
|
const testDbFile = join(testDataDir, 'test.paskiadb')
|
||||||
const dbPath = join(testDataDir, 'test.sqlite')
|
if (existsSync(testDbFile)) {
|
||||||
if (existsSync(dbPath)) {
|
console.log(' Removing test database...')
|
||||||
console.log(' Removing test database...')
|
rmSync(testDbFile, { force: true, recursive: true })
|
||||||
rmSync(dbPath)
|
|
||||||
}
|
|
||||||
// Remove wal/shm files too
|
|
||||||
for (const ext of ['-wal', '-shm']) {
|
|
||||||
const file = dbPath + ext
|
|
||||||
if (existsSync(file)) rmSync(file)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate Python coverage report if coverage was collected
|
// Generate Python coverage report if coverage was collected
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"types": ["bun-types"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["tests/**/*.ts", "playwright.config.ts"],
|
"include": ["tests/**/*.ts", "playwright.config.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|||||||
+38
-107
@@ -8,26 +8,6 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light dark; /* Automatic themes by browser */
|
color-scheme: light dark; /* Automatic themes by browser */
|
||||||
}
|
}
|
||||||
/* Login/reauth/forbidden dialog will appear in this iframe */
|
|
||||||
#auth-iframe {
|
|
||||||
/* Full viewport overlay */
|
|
||||||
border: none;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 9999;
|
|
||||||
/* Optional transparent background with optional blur backdrop */
|
|
||||||
color-scheme: auto;
|
|
||||||
background: transparent;
|
|
||||||
backdrop-filter: blur(.1rem) brightness(0.7);
|
|
||||||
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
|
||||||
}
|
|
||||||
/* Prevent background scroll when auth-iframe is shown */
|
|
||||||
body:has(#auth-iframe) {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -47,7 +27,7 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>API Mode (not leaving the page)</h2>
|
<h2>API Mode (not leaving the page)</h2>
|
||||||
<p>For SPAs and fetch() calls - shows auth in an iframe overlay:</p>
|
<p>For SPAs and fetch() calls - shows auth in an iframe overlay:</p>
|
||||||
<button onclick="apiCall('/auth/api/user-info', 'POST')">📋 Get User Info</button>
|
<button onclick="apiCall('/auth/api/user-info', 'GET')">📋 Get User Info</button>
|
||||||
<button onclick="apiCall('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
|
<button onclick="apiCall('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
|
||||||
<button onclick="apiCall('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
|
<button onclick="apiCall('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
|
||||||
<button onclick="logout()">🚪 Logout</button>
|
<button onclick="logout()">🚪 Logout</button>
|
||||||
@@ -65,99 +45,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script type="module">
|
||||||
|
import { apiFetch, apiJson, AuthCancelledError } from '/paskia-js/dist/paskia.js'
|
||||||
|
|
||||||
const output = document.getElementById('output');
|
const output = document.getElementById('output');
|
||||||
let pendingCall = null; // Stores the API call to retry after auth
|
|
||||||
|
|
||||||
// The auth iframe posts messages when authentication completes or is cancelled.
|
|
||||||
// Message types: 'auth-success' (proceed), 'auth-back' (user cancelled)
|
|
||||||
// Errors during auth stay in the dialog allowing retry, no message is sent.
|
|
||||||
window.addEventListener('message', (event) => {
|
|
||||||
const { type, message } = event.data || {};
|
|
||||||
|
|
||||||
if (type === 'auth-success') {
|
|
||||||
log('✓ Authentication successful, retrying...');
|
|
||||||
hideAuthIframe();
|
|
||||||
// Retry the original API call that triggered authentication
|
|
||||||
if (pendingCall) {
|
|
||||||
const { url, method } = pendingCall;
|
|
||||||
pendingCall = null;
|
|
||||||
apiCall(url, method);
|
|
||||||
}
|
|
||||||
} else if (type === 'auth-back') {
|
|
||||||
log(message || 'Authentication cancelled');
|
|
||||||
hideAuthIframe();
|
|
||||||
pendingCall = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Make an API call, handling 401/403 by showing the auth iframe.
|
|
||||||
// The server returns JSON with auth.iframe URL when authentication is needed.
|
|
||||||
async function apiCall(url, method = 'GET') {
|
|
||||||
log(`${method} ${url}...`);
|
|
||||||
|
|
||||||
const response = await fetch(url, { method, credentials: 'include' });
|
|
||||||
|
|
||||||
// Server returns 401 (login/reauth) or 403 (missing permissions)
|
|
||||||
// with a JSON body containing the iframe URL for authentication
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.auth?.iframe) {
|
|
||||||
const mode = data.auth.mode; // 'login' or 'reauth'
|
|
||||||
log(`${mode === 'reauth' ? 'Re-authentication' : 'Authentication'} required...`);
|
|
||||||
pendingCall = { url, method };
|
|
||||||
showAuthIframe(data.auth.iframe);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
log(`Error: ${response.status} - ${data.detail}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward endpoint returns 204 on success (Caddy then adds Remote-* headers)
|
|
||||||
if (response.status === 204) {
|
|
||||||
log('✓ Success (204 No Content)\nHeaders:\n' +
|
|
||||||
[...response.headers].filter(([k]) => k.startsWith('remote-'))
|
|
||||||
.map(([k, v]) => ` ${k}: ${v}`).join('\n'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
log(`Error: ${response.status} ${response.statusText}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
log('✓ Response:\n' + JSON.stringify(data, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
await fetch('/auth/api/logout', { method: 'POST', credentials: 'include' });
|
|
||||||
log('Logged out');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create fullscreen iframe for authentication.
|
|
||||||
// The 'allow' attribute enables WebAuthn (passkey) API inside the iframe.
|
|
||||||
function showAuthIframe(url) {
|
|
||||||
hideAuthIframe();
|
|
||||||
const iframe = document.createElement('iframe');
|
|
||||||
iframe.id = 'auth-iframe';
|
|
||||||
iframe.src = url;
|
|
||||||
document.body.appendChild(iframe);
|
|
||||||
log("Authentication dialog open...")
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideAuthIframe() {
|
|
||||||
document.getElementById('auth-iframe')?.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
function log(msg) {
|
function log(msg) {
|
||||||
output.textContent = msg;
|
output.textContent = msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make an API call using paskia module (handles 401/403 automatically)
|
||||||
|
window.apiCall = async function(url, method = 'GET') {
|
||||||
|
log(`${method} ${url}...`);
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(url, { method });
|
||||||
|
|
||||||
|
// Forward endpoint returns 204 on success
|
||||||
|
if (response.status === 204) {
|
||||||
|
log('✓ Success (204 No Content)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
log(`Error: ${response.status} ${response.statusText}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
log('✓ Response:\n' + JSON.stringify(data, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AuthCancelledError) {
|
||||||
|
log('Authentication cancelled');
|
||||||
|
} else {
|
||||||
|
log(`Error: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.logout = async function() {
|
||||||
|
await fetch('/auth/api/logout', { method: 'POST' });
|
||||||
|
log('Logged out');
|
||||||
|
}
|
||||||
|
|
||||||
// Browser mode: open the forward endpoint directly in a new window.
|
// Browser mode: open the forward endpoint directly in a new window.
|
||||||
// When Accept: text/html, the server redirects to the login page if needed,
|
window.browserNav = function(url) {
|
||||||
// then back to the original URL after authentication.
|
|
||||||
function browserNav(url) {
|
|
||||||
log('Opening in new window...\nIf not authenticated, you\'ll see the login page.\nAfter auth, you\'ll see a 204 response (blank page = success).');
|
log('Opening in new window...\nIf not authenticated, you\'ll see the login page.\nAfter auth, you\'ll see a 204 response (blank page = success).');
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-139
@@ -2,10 +2,10 @@
|
|||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<StatusMessage />
|
<StatusMessage />
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<HostProfileView v-if="authenticated && isHostMode" :initializing="loading" />
|
<HostProfileView v-if="viewState === 'profile' && isHostMode" />
|
||||||
<ProfileView v-else-if="authenticated" />
|
<ProfileView v-else-if="viewState === 'profile'" />
|
||||||
<LoadingView v-else-if="loading" :message="loadingMessage" />
|
<LoadingView v-else-if="viewState === 'loading'" :message="loadingMessage" />
|
||||||
<AuthRequiredMessage v-else-if="showBackMessage" @reload="reloadPage" />
|
<AccessDenied v-else-if="viewState === 'terminal'" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -13,18 +13,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { apiJson, getAuthIframeUrl } from '@/utils/api'
|
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
|
||||||
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
import StatusMessage from '@/components/StatusMessage.vue'
|
import StatusMessage from '@/components/StatusMessage.vue'
|
||||||
import ProfileView from '@/components/ProfileView.vue'
|
import ProfileView from '@/components/ProfileView.vue'
|
||||||
import HostProfileView from '@/components/HostProfileView.vue'
|
import HostProfileView from '@/components/HostProfileView.vue'
|
||||||
import LoadingView from '@/components/LoadingView.vue'
|
import LoadingView from '@/components/LoadingView.vue'
|
||||||
import AuthRequiredMessage from '@/components/AccessDenied.vue'
|
import AccessDenied from '@/components/AccessDenied.vue'
|
||||||
|
|
||||||
const store = useAuthStore()
|
const store = useAuthStore()
|
||||||
const loading = ref(true)
|
const viewState = ref('loading') // 'loading' | 'profile' | 'terminal'
|
||||||
const loadingMessage = ref('Loading...')
|
const loadingMessage = ref('Loading...')
|
||||||
const authenticated = ref(false)
|
|
||||||
const showBackMessage = ref(false)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize a host string for comparison (lowercase, strip default ports).
|
* Normalize a host string for comparison (lowercase, strip default ports).
|
||||||
@@ -48,128 +47,50 @@ const isHostMode = computed(() => {
|
|||||||
const configuredHost = normalizeHost(authHost)
|
const configuredHost = normalizeHost(authHost)
|
||||||
return currentHost !== configuredHost
|
return currentHost !== configuredHost
|
||||||
})
|
})
|
||||||
let validationTimer = null
|
|
||||||
let authIframe = null
|
function onSessionLost(e) {
|
||||||
|
store.userInfo = null
|
||||||
|
store.ctx = null
|
||||||
|
if (e?.name === 'AuthCancelledError') {
|
||||||
|
viewState.value = 'terminal'
|
||||||
|
} else {
|
||||||
|
store.showMessage(e?.message || 'Session lost', 'error', 5000)
|
||||||
|
viewState.value = 'terminal'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userUuidGetter = () => store.ctx?.user.uuid
|
||||||
|
const sessionValidator = new SessionValidator(userUuidGetter, onSessionLost)
|
||||||
|
|
||||||
|
onMounted(() => sessionValidator.start())
|
||||||
|
onUnmounted(() => sessionValidator.stop())
|
||||||
|
|
||||||
async function loadUserInfo() {
|
async function loadUserInfo() {
|
||||||
|
viewState.value = 'loading'
|
||||||
|
loadingMessage.value = 'Loading...'
|
||||||
try {
|
try {
|
||||||
store.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
|
// apiJson handles 401/403 with auth.iframe automatically:
|
||||||
authenticated.value = true
|
// shows overlay iframe, waits for auth, retries the request.
|
||||||
loading.value = false
|
const [validateData, userInfoData] = await Promise.all([
|
||||||
startSessionValidation()
|
apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
|
||||||
return true
|
apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
|
||||||
} catch (e) {
|
])
|
||||||
return false
|
store.userInfo = userInfoData
|
||||||
}
|
store.ctx = validateData.ctx
|
||||||
}
|
updateThemeFromSession(store.userInfo)
|
||||||
|
// Verify that the user UUIDs match between user-info and validate responses
|
||||||
async function showAuthIframe() {
|
if (store.userInfo.user.uuid !== store.ctx.user.uuid) {
|
||||||
// Remove existing iframe if any
|
console.error('User UUID mismatch between user-info and validate responses')
|
||||||
hideAuthIframe()
|
window.location.reload()
|
||||||
|
return
|
||||||
// Create new iframe for authentication using src URL
|
|
||||||
const url = await getAuthIframeUrl('login')
|
|
||||||
authIframe = document.createElement('iframe')
|
|
||||||
authIframe.id = 'auth-iframe'
|
|
||||||
authIframe.title = 'Authentication'
|
|
||||||
authIframe.allow = 'publickey-credentials-get; publickey-credentials-create'
|
|
||||||
authIframe.src = url
|
|
||||||
document.body.appendChild(authIframe)
|
|
||||||
loadingMessage.value = 'Authentication required...'
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideAuthIframe() {
|
|
||||||
if (authIframe) {
|
|
||||||
authIframe.remove()
|
|
||||||
authIframe = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reloadPage() {
|
|
||||||
window.location.reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAuthMessage(event) {
|
|
||||||
const data = event.data
|
|
||||||
if (!data?.type) return
|
|
||||||
|
|
||||||
switch (data.type) {
|
|
||||||
case 'auth-success':
|
|
||||||
// Authentication successful - reload user info
|
|
||||||
hideAuthIframe()
|
|
||||||
loading.value = true
|
|
||||||
loadingMessage.value = 'Loading user profile...'
|
|
||||||
loadUserInfo()
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'auth-error':
|
|
||||||
// Authentication failed - keep iframe open so user can retry
|
|
||||||
if (data.cancelled) {
|
|
||||||
console.log('Authentication cancelled by user')
|
|
||||||
} else {
|
|
||||||
store.showMessage(data.message || 'Authentication failed', 'error', 5000)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'auth-cancelled':
|
|
||||||
// Legacy support - treat as auth-error with cancelled flag
|
|
||||||
console.log('Authentication cancelled')
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'auth-back':
|
|
||||||
// User clicked Back - show message with reload option
|
|
||||||
hideAuthIframe()
|
|
||||||
loading.value = false
|
|
||||||
showBackMessage.value = true
|
|
||||||
store.showMessage('Authentication cancelled', 'info', 3000)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'auth-close-request':
|
|
||||||
// Legacy support - treat as back
|
|
||||||
hideAuthIframe()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateSession() {
|
|
||||||
try {
|
|
||||||
await apiJson('/auth/api/validate', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
// If successful, session was renewed automatically
|
|
||||||
} catch (error) {
|
|
||||||
if (error.status === 401) {
|
|
||||||
// Session expired - need to re-authenticate
|
|
||||||
console.log('Session expired, requiring re-authentication')
|
|
||||||
authenticated.value = false
|
|
||||||
loading.value = true
|
|
||||||
stopSessionValidation()
|
|
||||||
showAuthIframe()
|
|
||||||
} else {
|
|
||||||
console.error('Session validation error:', error)
|
|
||||||
// Don't treat network errors as session expiry
|
|
||||||
}
|
}
|
||||||
}
|
viewState.value = 'profile'
|
||||||
}
|
} catch (e) {
|
||||||
|
onSessionLost(e)
|
||||||
function startSessionValidation() {
|
|
||||||
// Validate session every 2 minutes
|
|
||||||
stopSessionValidation()
|
|
||||||
validationTimer = setInterval(validateSession, 2 * 60 * 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopSessionValidation() {
|
|
||||||
if (validationTimer) {
|
|
||||||
clearInterval(validationTimer)
|
|
||||||
validationTimer = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Listen for postMessage from auth iframe
|
|
||||||
window.addEventListener('message', handleAuthMessage)
|
|
||||||
|
|
||||||
// Load settings
|
// Load settings
|
||||||
await store.loadSettings()
|
await store.loadSettings()
|
||||||
|
|
||||||
@@ -183,21 +104,7 @@ onMounted(async () => {
|
|||||||
document.title = inHostMode ? `${rpName} · Account summary` : rpName
|
document.title = inHostMode ? `${rpName} · Account summary` : rpName
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to load user info
|
// Load user info (apiJson handles auth iframe if needed)
|
||||||
const success = await loadUserInfo()
|
await loadUserInfo()
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
// Need authentication - show login iframe
|
|
||||||
showAuthIframe()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener('message', handleAuthMessage)
|
|
||||||
stopSessionValidation()
|
|
||||||
hideAuthIframe()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
</style>
|
|
||||||
|
|||||||
+449
-164
@@ -5,15 +5,19 @@ import CredentialList from '@/components/CredentialList.vue'
|
|||||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||||
import StatusMessage from '@/components/StatusMessage.vue'
|
import StatusMessage from '@/components/StatusMessage.vue'
|
||||||
import LoadingView from '@/components/LoadingView.vue'
|
import LoadingView from '@/components/LoadingView.vue'
|
||||||
import AuthRequiredMessage from '@/components/AccessDenied.vue'
|
import AccessDenied from '@/components/AccessDenied.vue'
|
||||||
import AdminOverview from '@/admin/AdminOverview.vue'
|
import AdminOverview from '@/admin/AdminOverview.vue'
|
||||||
import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
|
import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
|
||||||
import AdminUserDetail from '@/admin/AdminUserDetail.vue'
|
import AdminUserDetail from '@/admin/AdminUserDetail.vue'
|
||||||
|
import AdminOidcDetail from '@/admin/AdminOidcDetail.vue'
|
||||||
import AdminDialogs from '@/admin/AdminDialogs.vue'
|
import AdminDialogs from '@/admin/AdminDialogs.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings'
|
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
||||||
import { apiJson } from '@/utils/api'
|
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
|
||||||
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
|
import { uuidv7 } from 'uuidv7'
|
||||||
import { getDirection } from '@/utils/keynav'
|
import { getDirection } from '@/utils/keynav'
|
||||||
|
import { goBack } from '@/utils/helpers'
|
||||||
|
|
||||||
const info = ref(null)
|
const info = ref(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -23,9 +27,12 @@ const showBackMessage = ref(false)
|
|||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const orgs = ref([])
|
const orgs = ref([])
|
||||||
const permissions = ref([])
|
const permissions = ref([])
|
||||||
|
const oidcClients = ref([])
|
||||||
const currentOrgId = ref(null) // UUID of selected org for detail view
|
const currentOrgId = ref(null) // UUID of selected org for detail view
|
||||||
const currentUserId = ref(null) // UUID for user detail view
|
const currentUserId = ref(null) // UUID for user detail view
|
||||||
|
const currentOidcId = ref(null) // UUID for OIDC client detail view
|
||||||
const userDetail = ref(null) // cached user detail object
|
const userDetail = ref(null) // cached user detail object
|
||||||
|
const editingOidcClient = ref(null) // OIDC client being edited (with local changes)
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const addingOrgForPermission = ref(null)
|
const addingOrgForPermission = ref(null)
|
||||||
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
|
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
|
||||||
@@ -42,10 +49,15 @@ const breadcrumbsRef = ref(null)
|
|||||||
const adminOverviewRef = ref(null)
|
const adminOverviewRef = ref(null)
|
||||||
const adminOrgDetailRef = ref(null)
|
const adminOrgDetailRef = ref(null)
|
||||||
const adminUserDetailRef = ref(null)
|
const adminUserDetailRef = ref(null)
|
||||||
|
const adminOidcDetailRef = ref(null)
|
||||||
|
|
||||||
// Check if any modal/dialog is open (blocks arrow key navigation)
|
// Check if any modal/dialog is open (blocks arrow key navigation)
|
||||||
const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value)
|
const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value)
|
||||||
|
|
||||||
|
// Derive admin status from permissions
|
||||||
|
const isMasterAdmin = computed(() => info.value?.ctx.permissions.includes('auth:admin'))
|
||||||
|
const isOrgAdmin = computed(() => info.value?.ctx.permissions.includes('auth:org:admin'))
|
||||||
|
|
||||||
function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') }
|
function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') }
|
||||||
|
|
||||||
function handleGlobalClick(e) {
|
function handleGlobalClick(e) {
|
||||||
@@ -60,8 +72,8 @@ function handleGlobalClick(e) {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
document.addEventListener('click', handleGlobalClick)
|
document.addEventListener('click', handleGlobalClick)
|
||||||
window.addEventListener('hashchange', parseHash)
|
window.addEventListener('hashchange', parseHash)
|
||||||
const settings = await getSettings()
|
await authStore.loadSettings()
|
||||||
if (settings?.rp_name) document.title = settings.rp_name + ' Admin'
|
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
|
||||||
await load()
|
await load()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -74,11 +86,12 @@ onUnmounted(() => {
|
|||||||
const permissionSummary = computed(() => {
|
const permissionSummary = computed(() => {
|
||||||
const summary = {}
|
const summary = {}
|
||||||
for (const o of orgs.value) {
|
for (const o of orgs.value) {
|
||||||
const orgBase = { uuid: o.uuid, display_name: o.display_name }
|
const orgBase = { uuid: o.uuid, display_name: o.org.display_name }
|
||||||
const orgPerms = new Set(o.permissions || [])
|
// o.permissions is a dict[UUID, Permission]
|
||||||
|
const orgPermUuids = new Set(Object.keys(o.permissions || {}))
|
||||||
|
|
||||||
// Org-level permissions (direct) - only count if org can grant them
|
// Org-level permissions (direct) - only count if org can grant them
|
||||||
for (const pid of o.permissions || []) {
|
for (const pid of Object.keys(o.permissions || {})) {
|
||||||
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
|
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
|
||||||
if (!summary[pid].orgSet.has(o.uuid)) {
|
if (!summary[pid].orgSet.has(o.uuid)) {
|
||||||
summary[pid].orgs.push(orgBase)
|
summary[pid].orgs.push(orgBase)
|
||||||
@@ -87,17 +100,18 @@ const permissionSummary = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Role-based permissions (inheritance) - only count if org can grant them
|
// Role-based permissions (inheritance) - only count if org can grant them
|
||||||
for (const r of o.roles) {
|
for (const [roleUuid, r] of Object.entries(o.roles || {})) {
|
||||||
for (const pid of r.permissions) {
|
// r.permissions is dict[UUID, bool]
|
||||||
|
for (const pid of Object.keys(r.permissions || {})) {
|
||||||
// Only count if the org can grant this permission
|
// Only count if the org can grant this permission
|
||||||
if (!orgPerms.has(pid)) continue
|
if (!orgPermUuids.has(pid)) continue
|
||||||
|
|
||||||
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
|
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
|
||||||
if (!summary[pid].orgSet.has(o.uuid)) {
|
if (!summary[pid].orgSet.has(o.uuid)) {
|
||||||
summary[pid].orgs.push(orgBase)
|
summary[pid].orgs.push(orgBase)
|
||||||
summary[pid].orgSet.add(o.uuid)
|
summary[pid].orgSet.add(o.uuid)
|
||||||
}
|
}
|
||||||
summary[pid].userCount += r.users.length
|
summary[pid].userCount += roleUserCount(o, roleUuid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,66 +122,131 @@ const permissionSummary = computed(() => {
|
|||||||
return display
|
return display
|
||||||
})
|
})
|
||||||
|
|
||||||
function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p, id: p.id, display_name: p.display_name }) }
|
function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p, scope: p.scope, display_name: p.display_name, domain: p.domain || '' }) }
|
||||||
|
|
||||||
|
|
||||||
function parseHash() {
|
function parseHash() {
|
||||||
const h = window.location.hash || ''
|
const h = window.location.hash || ''
|
||||||
currentOrgId.value = null
|
currentOrgId.value = null
|
||||||
currentUserId.value = null
|
currentUserId.value = null
|
||||||
|
currentOidcId.value = null
|
||||||
|
editingOidcClient.value = null
|
||||||
if (h.startsWith('#org/')) {
|
if (h.startsWith('#org/')) {
|
||||||
currentOrgId.value = h.slice(5)
|
currentOrgId.value = h.slice(5)
|
||||||
} else if (h.startsWith('#user/')) {
|
} else if (h.startsWith('#user/')) {
|
||||||
currentUserId.value = h.slice(6)
|
currentUserId.value = h.slice(6)
|
||||||
|
} else if (h.startsWith('#oidc:')) {
|
||||||
|
const oidcUuid = h.slice(6)
|
||||||
|
currentOidcId.value = oidcUuid
|
||||||
|
// Initialize editing client data
|
||||||
|
if (oidcUuid === 'new') {
|
||||||
|
// Generate client_id and secret for new client
|
||||||
|
const bytes = new Uint8Array(32)
|
||||||
|
crypto.getRandomValues(bytes)
|
||||||
|
const client_secret = btoa(String.fromCharCode(...bytes))
|
||||||
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
|
editingOidcClient.value = {
|
||||||
|
client_id: uuidv7(),
|
||||||
|
client_secret,
|
||||||
|
isNew: true,
|
||||||
|
name: '',
|
||||||
|
redirect_uris: []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const client = oidcClients.value.find(c => c.uuid === oidcUuid)
|
||||||
|
if (client) {
|
||||||
|
editingOidcClient.value = {
|
||||||
|
...client,
|
||||||
|
client_id: client.uuid,
|
||||||
|
client_secret: null,
|
||||||
|
isNew: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadOrgs() {
|
async function loadAdminData() {
|
||||||
const data = await apiJson('/auth/api/admin/orgs')
|
const data = await apiJson('/auth/api/admin/info')
|
||||||
orgs.value = data.map(o => {
|
// Convert dicts to arrays with uuid added
|
||||||
const roles = o.roles.map(r => ({ ...r, org_uuid: o.uuid, users: [] }))
|
orgs.value = Object.entries(data.orgs).map(([uuid, o]) => ({ uuid, ...o }))
|
||||||
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r]))
|
permissions.value = Object.entries(data.permissions).map(([uuid, p]) => ({ uuid, ...p }))
|
||||||
for (const u of o.users || []) {
|
oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c }))
|
||||||
if (roleMap[u.role]) roleMap[u.role].users.push(u)
|
|
||||||
}
|
|
||||||
return { ...o, roles }
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPermissions() {
|
// Helper to get users for a role as sorted array of [uuid, user]
|
||||||
permissions.value = await apiJson('/auth/api/admin/permissions')
|
function roleUsers(org, roleUuid) {
|
||||||
|
return Object.entries(org.users)
|
||||||
|
.filter(([_, u]) => u.role === roleUuid)
|
||||||
|
.sort(([, a], [, b]) => {
|
||||||
|
const nameA = a.display_name.toLowerCase()
|
||||||
|
const nameB = b.display_name.toLowerCase()
|
||||||
|
return nameA.localeCompare(nameB)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to count users in a role
|
||||||
|
function roleUserCount(org, roleUuid) {
|
||||||
|
return Object.values(org.users).filter(u => u.role === roleUuid).length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to count total users in an org
|
||||||
|
function orgUserCount(org) {
|
||||||
|
return Object.keys(org.users).length
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUserInfo() {
|
async function loadUserInfo() {
|
||||||
info.value = await apiJson('/auth/api/user-info', { method: 'POST' })
|
const data = await apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
||||||
|
info.value = data
|
||||||
|
updateThemeFromSession(data.ctx)
|
||||||
authenticated.value = true
|
authenticated.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSensitiveState() {
|
||||||
|
info.value = null
|
||||||
|
orgs.value = []
|
||||||
|
permissions.value = []
|
||||||
|
oidcClients.value = []
|
||||||
|
userDetail.value = null
|
||||||
|
editingOidcClient.value = null
|
||||||
|
authenticated.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSessionLost(e) {
|
||||||
|
clearSensitiveState()
|
||||||
|
if (e.name === 'AuthCancelledError') {
|
||||||
|
showBackMessage.value = true
|
||||||
|
} else {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userUuidGetter = () => info.value?.ctx.user.uuid
|
||||||
|
const sessionValidator = new SessionValidator(userUuidGetter, onSessionLost)
|
||||||
|
|
||||||
|
onMounted(() => sessionValidator.start())
|
||||||
|
onUnmounted(() => sessionValidator.stop())
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadingMessage.value = 'Loading...'
|
loadingMessage.value = 'Loading...'
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
// Load admin data first - apiJson will handle 401/403 with iframe authentication
|
// Load admin data first - apiJson will handle 401/403 with iframe authentication
|
||||||
await Promise.all([loadOrgs(), loadPermissions()])
|
await loadAdminData()
|
||||||
// If we get here, user has admin access - now fetch user info for display
|
// If we get here, user has admin access - now fetch user info for display
|
||||||
await loadUserInfo()
|
await loadUserInfo()
|
||||||
|
|
||||||
if (!info.value.is_global_admin && info.value.is_org_admin && orgs.value.length === 1) {
|
if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) {
|
||||||
if (!window.location.hash || window.location.hash === '#overview') {
|
if (!window.location.hash || window.location.hash === '#overview') {
|
||||||
currentOrgId.value = orgs.value[0].uuid
|
currentOrgId.value = orgs.value[0].uuid
|
||||||
window.location.hash = `#org/${currentOrgId.value}`
|
window.location.hash = `#org/${currentOrgId.value}`
|
||||||
authStore.showMessage(`Navigating to ${orgs.value[0].display_name} Administration`, 'info', 3000)
|
|
||||||
} else {
|
} else {
|
||||||
parseHash()
|
parseHash()
|
||||||
}
|
}
|
||||||
} else parseHash()
|
} else parseHash()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.name === 'AuthCancelledError') {
|
onSessionLost(e)
|
||||||
showBackMessage.value = true
|
|
||||||
} else {
|
|
||||||
error.value = e.message
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -182,19 +261,17 @@ function editUserName(user) { openDialog('user-update-name', { user, name: user.
|
|||||||
|
|
||||||
async function performOrgDeletion(orgUuid) {
|
async function performOrgDeletion(orgUuid) {
|
||||||
await apiJson(`/auth/api/admin/orgs/${orgUuid}`, { method: 'DELETE' })
|
await apiJson(`/auth/api/admin/orgs/${orgUuid}`, { method: 'DELETE' })
|
||||||
await Promise.all([loadOrgs(), loadPermissions()])
|
await Promise.all([loadAdminData()])
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteOrg(org) {
|
function deleteOrg(org) {
|
||||||
if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return }
|
const userCount = orgUserCount(org)
|
||||||
|
|
||||||
const userCount = org.roles.reduce((acc, r) => acc + r.users.length, 0)
|
|
||||||
|
|
||||||
if (userCount === 0) {
|
if (userCount === 0) {
|
||||||
// No users in the organization, safe to delete directly
|
// No users in the organization, safe to delete directly
|
||||||
performOrgDeletion(org.uuid)
|
performOrgDeletion(org.uuid)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Organization "${org.display_name}" deleted.`, 'success', 2500)
|
authStore.showMessage(`Organization "${org.org.display_name}" deleted.`, 'success', 2500)
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to delete organization', 'error')
|
authStore.showMessage(e.message || 'Failed to delete organization', 'error')
|
||||||
@@ -203,50 +280,68 @@ function deleteOrg(org) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build detailed breakdown of users by role
|
// Build detailed breakdown of users by role
|
||||||
const roleParts = org.roles
|
const roleParts = Object.entries(org.roles)
|
||||||
.filter(r => r.users.length > 0)
|
.map(([uuid, r]) => ({ role: r, count: roleUserCount(org, uuid) }))
|
||||||
.map(r => `${r.users.length} ${r.display_name}`)
|
.filter(x => x.count > 0)
|
||||||
|
.map(x => `${x.count} ${x.role.display_name}`)
|
||||||
|
|
||||||
const affects = roleParts.join(', ')
|
const affects = roleParts.join(', ')
|
||||||
|
|
||||||
openDialog('confirm', { message: `Delete organization "${org.display_name}", including accounts of ${affects})?`, action: async () => {
|
openDialog('confirm', { message: `Delete organization "${org.org.display_name}", including accounts of ${affects})?`, action: async () => {
|
||||||
await performOrgDeletion(org.uuid)
|
await performOrgDeletion(org.uuid)
|
||||||
} })
|
} })
|
||||||
}
|
}
|
||||||
|
|
||||||
function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
|
function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
|
||||||
|
|
||||||
async function moveUserToRole(org, user, targetRoleDisplayName) {
|
function deleteUser(user, userDetail) {
|
||||||
if (user.role === targetRoleDisplayName) return
|
const credentialCount = userDetail?.credentials ? Object.keys(userDetail.credentials).length : 0
|
||||||
|
const userUuid = user.uuid
|
||||||
|
const userName = user.display_name
|
||||||
|
const orgUuid = user.org // org UUID is stored in selectedUser
|
||||||
|
|
||||||
|
if (credentialCount === 0) {
|
||||||
|
// No credentials, safe to delete directly
|
||||||
|
performUserDeletion(userUuid, userName, orgUuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const passkeys = credentialCount === 1 ? '1 passkey' : `${credentialCount} passkeys`
|
||||||
|
openDialog('confirm', {
|
||||||
|
message: `Delete user "${userName}" with ${passkeys}? This action cannot be undone.`,
|
||||||
|
action: async () => {
|
||||||
|
await performUserDeletion(userUuid, userName, orgUuid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performUserDeletion(userUuid, userName, orgUuid) {
|
||||||
try {
|
try {
|
||||||
await apiJson(`/auth/api/admin/orgs/${org.uuid}/users/${user.uuid}/role`, {
|
await apiJson(`/auth/api/admin/users/${userUuid}`, { method: 'DELETE' })
|
||||||
method: 'PUT',
|
authStore.showMessage(`User "${userName}" deleted.`, 'success', 2500)
|
||||||
body: { role: targetRoleDisplayName }
|
await loadAdminData()
|
||||||
|
window.location.hash = `#org/${orgUuid}`
|
||||||
|
} catch (e) {
|
||||||
|
authStore.showMessage(e.message || 'Failed to delete user', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveUserToRole(userUuid, user, targetRoleUuid) {
|
||||||
|
if (user.role === targetRoleUuid) return
|
||||||
|
try {
|
||||||
|
await apiJson(`/auth/api/admin/users/${userUuid}/role`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { role_uuid: targetRoleUuid }
|
||||||
})
|
})
|
||||||
await loadOrgs()
|
await loadAdminData()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authStore.showMessage(e.message || 'Failed to update user role')
|
authStore.showMessage(e.message || 'Failed to update user role')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUserDragStart(e, user, org_uuid) {
|
function moveUserToRoleFromDrag(userUuid, newRoleUuid) {
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
const user = selectedOrg.value?.users?.[userUuid]
|
||||||
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: user.uuid, org_uuid }))
|
if (user) moveUserToRole(userUuid, user, newRoleUuid)
|
||||||
}
|
|
||||||
|
|
||||||
function onRoleDragOver(e) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.dataTransfer.dropEffect = 'move'
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRoleDrop(e, org, role) {
|
|
||||||
e.preventDefault()
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(e.dataTransfer.getData('text/plain'))
|
|
||||||
if (data.org_uuid !== org.uuid) return // only within same org
|
|
||||||
const user = org.roles.flatMap(r => r.users).find(u => u.uuid === data.user_uuid)
|
|
||||||
if (user) moveUserToRole(org, user, role.display_name)
|
|
||||||
} catch (_) { /* ignore */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role actions
|
// Role actions
|
||||||
@@ -256,10 +351,10 @@ function updateRole(role) { openDialog('role-update', { role, name: role.display
|
|||||||
|
|
||||||
function deleteRole(role) {
|
function deleteRole(role) {
|
||||||
// UI only allows deleting empty roles, so no confirmation needed
|
// UI only allows deleting empty roles, so no confirmation needed
|
||||||
apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'DELETE' })
|
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'DELETE' })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500)
|
authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500)
|
||||||
loadOrgs()
|
loadAdminData()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to delete role', 'error')
|
authStore.showMessage(e.message || 'Failed to delete role', 'error')
|
||||||
@@ -267,21 +362,22 @@ function deleteRole(role) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleRolePermission(role, pid, checked) {
|
async function toggleRolePermission(role, pid, checked) {
|
||||||
// Calculate new permissions array
|
// Optimistic update - role.permissions is dict[UUID, bool]
|
||||||
const newPermissions = checked
|
const prevPermissions = { ...role.permissions }
|
||||||
? [...role.permissions, pid]
|
const newPermissions = { ...role.permissions }
|
||||||
: role.permissions.filter(p => p !== pid)
|
if (checked) {
|
||||||
|
newPermissions[pid] = true
|
||||||
// Optimistic update
|
} else {
|
||||||
const prevPermissions = [...role.permissions]
|
delete newPermissions[pid]
|
||||||
|
}
|
||||||
role.permissions = newPermissions
|
role.permissions = newPermissions
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, {
|
const method = checked ? 'POST' : 'DELETE'
|
||||||
method: 'PUT',
|
await apiJson(`/auth/api/admin/roles/${role.uuid}/permissions/${pid}`, {
|
||||||
body: { display_name: role.display_name, permissions: newPermissions }
|
method
|
||||||
})
|
})
|
||||||
await loadOrgs()
|
await loadAdminData()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authStore.showMessage(e.message || 'Failed to update role permission')
|
authStore.showMessage(e.message || 'Failed to update role permission')
|
||||||
role.permissions = prevPermissions // revert
|
role.permissions = prevPermissions // revert
|
||||||
@@ -289,20 +385,20 @@ async function toggleRolePermission(role, pid, checked) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Permission actions
|
// Permission actions
|
||||||
async function performPermissionDeletion(permissionId) {
|
async function performPermissionDeletion(permissionUuid) {
|
||||||
const params = new URLSearchParams({ permission_id: permissionId })
|
const params = new URLSearchParams({ permission_uuid: permissionUuid })
|
||||||
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' })
|
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' })
|
||||||
await loadPermissions()
|
await loadAdminData()
|
||||||
}
|
}
|
||||||
|
|
||||||
function deletePermission(p) {
|
function deletePermission(p) {
|
||||||
const userCount = permissionSummary.value[p.id]?.userCount || 0
|
const userCount = permissionSummary.value[p.uuid]?.userCount || 0
|
||||||
|
|
||||||
// Count roles that have this permission
|
// Count roles that have this permission
|
||||||
let roleCount = 0
|
let roleCount = 0
|
||||||
for (const org of orgs.value) {
|
for (const org of orgs.value) {
|
||||||
for (const role of org.roles) {
|
for (const role of Object.values(org.roles)) {
|
||||||
if (role.permissions.includes(p.id)) {
|
if (p.uuid in (role.permissions || {})) {
|
||||||
roleCount++
|
roleCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,7 +406,7 @@ function deletePermission(p) {
|
|||||||
|
|
||||||
if (roleCount === 0) {
|
if (roleCount === 0) {
|
||||||
// No roles have this permission, safe to delete directly
|
// No roles have this permission, safe to delete directly
|
||||||
performPermissionDeletion(p.id)
|
performPermissionDeletion(p.uuid)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Permission "${p.display_name}" deleted.`, 'success', 2500)
|
authStore.showMessage(`Permission "${p.display_name}" deleted.`, 'success', 2500)
|
||||||
})
|
})
|
||||||
@@ -326,12 +422,106 @@ function deletePermission(p) {
|
|||||||
const affects = parts.join(', ')
|
const affects = parts.join(', ')
|
||||||
|
|
||||||
openDialog('confirm', { message: `Delete permission "${p.display_name}" (${affects})?`, action: async () => {
|
openDialog('confirm', { message: `Delete permission "${p.display_name}" (${affects})?`, action: async () => {
|
||||||
await performPermissionDeletion(p.id)
|
await performPermissionDeletion(p.uuid)
|
||||||
} })
|
} })
|
||||||
}
|
}
|
||||||
|
|
||||||
function reloadPage() {
|
// OIDC Client actions
|
||||||
window.location.reload()
|
async function sha256Hex(text) {
|
||||||
|
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
|
||||||
|
return [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOidcClient() {
|
||||||
|
// Navigate to new OIDC client page
|
||||||
|
window.location.hash = '#oidc:new'
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOidcClient(client) {
|
||||||
|
// Navigate to OIDC client detail page
|
||||||
|
window.location.hash = `#oidc:${client.uuid}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetOidcSecret(clientId) {
|
||||||
|
// Generate new secret locally; it will be sent to server on Save
|
||||||
|
const bytes = new Uint8Array(32)
|
||||||
|
crypto.getRandomValues(bytes)
|
||||||
|
const client_secret = btoa(String.fromCharCode(...bytes))
|
||||||
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
|
// Update editingOidcClient if we're on the detail page
|
||||||
|
if (editingOidcClient.value?.client_id === clientId) {
|
||||||
|
editingOidcClient.value = { ...editingOidcClient.value, client_secret }
|
||||||
|
}
|
||||||
|
// Also update dialog if open (for backwards compatibility)
|
||||||
|
if (dialog.value.type === 'oidc-edit' && dialog.value.data?.client_id === clientId) {
|
||||||
|
dialog.value.data.client_secret = client_secret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPermissionForClient(clientId) {
|
||||||
|
openDialog('perm-create', { display_name: '', scope: '', domain: clientId })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openServerConfig() {
|
||||||
|
try {
|
||||||
|
const config = await apiJson('/auth/api/admin/server-config')
|
||||||
|
// Strip https:// scheme from stored origins and auth_host for editing
|
||||||
|
const origins = (config.origins || []).map(o => o.replace(/^https:\/\//, ''))
|
||||||
|
const auth_host = (config.auth_host || '').replace(/^https:\/\//, '')
|
||||||
|
openDialog('server-config', {
|
||||||
|
rp_name: config.rp_name || '',
|
||||||
|
auth_host,
|
||||||
|
origins,
|
||||||
|
originValidation: origins.map(() => null),
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
authStore.showMessage(e.message || 'Failed to load server configuration', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteOidcClient(client) {
|
||||||
|
openDialog('confirm', {
|
||||||
|
message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`,
|
||||||
|
action: async () => {
|
||||||
|
await performOidcClientDeletion(client.uuid, client.name)
|
||||||
|
// Navigate back to overview if we were on the detail page
|
||||||
|
if (currentOidcId.value === client.uuid) {
|
||||||
|
window.location.hash = '#overview'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performOidcClientDeletion(clientUuid, clientName) {
|
||||||
|
await apiJson(`/auth/api/admin/oidc-clients/${clientUuid}`, { method: 'DELETE' })
|
||||||
|
authStore.showMessage(`OIDC client "${clientName}" deleted.`, 'success', 2500)
|
||||||
|
await loadAdminData()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOidcSave(data) {
|
||||||
|
const { client_id, client_secret, name, redirect_uris, isNew } = data
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (client_secret) {
|
||||||
|
const secret_hash = await sha256Hex(client_secret)
|
||||||
|
if (isNew) {
|
||||||
|
await apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } })
|
||||||
|
} else {
|
||||||
|
await apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } })
|
||||||
|
}
|
||||||
|
authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500)
|
||||||
|
await loadAdminData()
|
||||||
|
window.location.hash = '#overview'
|
||||||
|
} catch (e) {
|
||||||
|
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOidcCancel() {
|
||||||
|
goOverview()
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
|
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
|
||||||
@@ -351,37 +541,44 @@ function openUser(u) {
|
|||||||
const selectedUser = computed(() => {
|
const selectedUser = computed(() => {
|
||||||
if (!currentUserId.value) return null
|
if (!currentUserId.value) return null
|
||||||
for (const o of orgs.value) {
|
for (const o of orgs.value) {
|
||||||
for (const r of o.roles) {
|
const u = o.users[currentUserId.value]
|
||||||
const u = r.users.find(x => x.uuid === currentUserId.value)
|
if (u) {
|
||||||
if (u) return { ...u, org_uuid: o.uuid, role_display_name: r.display_name }
|
const role = o.roles[u.role]
|
||||||
|
return { ...u, uuid: currentUserId.value, org: o.uuid, role_display_name: role?.display_name }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
const pageHeading = computed(() => {
|
|
||||||
if (selectedUser.value) return 'Admin: User'
|
|
||||||
if (selectedOrg.value) return 'Admin: Org'
|
|
||||||
return ((authStore.settings?.rp_name) || 'Master') + ' Admin'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Breadcrumb entries for admin app.
|
// Breadcrumb entries for admin app.
|
||||||
const breadcrumbEntries = computed(() => {
|
const breadcrumbEntries = computed(() => {
|
||||||
const entries = [
|
const entries = [
|
||||||
{ label: 'Auth', href: makeUiHref() },
|
{ label: 'My Profile', href: makeUiHref() }
|
||||||
{ label: 'Admin', href: adminUiPath() }
|
|
||||||
]
|
]
|
||||||
|
// For org admins, combine Admin and their org
|
||||||
|
if (isOrgAdmin.value && !isMasterAdmin.value && orgs.value.length > 0) {
|
||||||
|
const org = orgs.value[0]
|
||||||
|
entries.push({ label: `Admin: ${org.org.display_name}`, href: `#org/${org.uuid}` })
|
||||||
|
} else {
|
||||||
|
entries.push({ label: 'Admin', href: adminUiPath() })
|
||||||
|
}
|
||||||
// Determine organization for user view if selectedOrg not explicitly chosen.
|
// Determine organization for user view if selectedOrg not explicitly chosen.
|
||||||
let orgForUser = null
|
let orgForUser = null
|
||||||
if (selectedUser.value) {
|
if (selectedUser.value) {
|
||||||
orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org_uuid) || null
|
orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org) || null
|
||||||
}
|
}
|
||||||
const orgToShow = selectedOrg.value || orgForUser
|
const orgToShow = selectedOrg.value || orgForUser
|
||||||
if (orgToShow) {
|
// Add org breadcrumb only if it's not already included in the Admin entry
|
||||||
entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` })
|
const adminOrg = (isOrgAdmin.value && !isMasterAdmin.value && orgs.value.length > 0) ? orgs.value[0] : null
|
||||||
|
if (orgToShow && (!adminOrg || orgToShow.uuid !== adminOrg.uuid)) {
|
||||||
|
entries.push({ label: orgToShow.org.display_name, href: `#org/${orgToShow.uuid}` })
|
||||||
|
}
|
||||||
|
if (currentOidcId.value) {
|
||||||
|
const label = editingOidcClient.value?.isNew ? 'New Client' : (editingOidcClient.value?.name || 'OIDC Client')
|
||||||
|
entries.push({ label, href: `#oidc:${currentOidcId.value}` })
|
||||||
}
|
}
|
||||||
if (selectedUser.value) {
|
if (selectedUser.value) {
|
||||||
entries.push({ label: selectedUser.value.display_name || 'User', href: `#user/${selectedUser.value.uuid}` })
|
entries.push({ label: selectedUser.value.display_name, href: `#user/${selectedUser.value.uuid}` })
|
||||||
}
|
}
|
||||||
return entries
|
return entries
|
||||||
})
|
})
|
||||||
@@ -389,7 +586,7 @@ const breadcrumbEntries = computed(() => {
|
|||||||
watch(selectedUser, async (u) => {
|
watch(selectedUser, async (u) => {
|
||||||
if (!u) { userDetail.value = null; return }
|
if (!u) { userDetail.value = null; return }
|
||||||
try {
|
try {
|
||||||
userDetail.value = await apiJson(`/auth/api/admin/orgs/${u.org_uuid}/users/${u.uuid}`)
|
userDetail.value = await apiJson(`/auth/api/admin/users/${u.uuid}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
userDetail.value = { error: e.message }
|
userDetail.value = { error: e.message }
|
||||||
}
|
}
|
||||||
@@ -401,20 +598,29 @@ function generateUserRegistrationLink(u) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleOrgPermission(org, permId, checked) {
|
async function toggleOrgPermission(org, permId, checked) {
|
||||||
// Build next permission list
|
// org.permissions is dict[UUID, Permission]
|
||||||
const has = org.permissions.includes(permId)
|
const has = permId in org.permissions
|
||||||
if (checked && has) return
|
if (checked && has) return
|
||||||
if (!checked && !has) return
|
if (!checked && !has) return
|
||||||
const next = checked ? [...org.permissions, permId] : org.permissions.filter(p => p !== permId)
|
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
const prev = [...org.permissions]
|
const prev = { ...org.permissions }
|
||||||
org.permissions = next
|
if (checked) {
|
||||||
|
// Need to fetch the permission object to add it
|
||||||
|
const perm = permissions.value.find(p => p.uuid === permId)
|
||||||
|
if (perm) {
|
||||||
|
org.permissions = { ...org.permissions, [permId]: perm }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const next = { ...org.permissions }
|
||||||
|
delete next[permId]
|
||||||
|
org.permissions = next
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ permission_id: permId })
|
const params = new URLSearchParams({ permission_uuid: permId })
|
||||||
await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
|
await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
|
||||||
await loadOrgs()
|
await loadAdminData()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authStore.showMessage(e.message || 'Failed to update organization permission')
|
authStore.showMessage(e.message || 'Failed to update organization permission', 'error')
|
||||||
org.permissions = prev // revert
|
org.permissions = prev // revert
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -522,18 +728,15 @@ function handlePanelNavigateOut(direction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshUserDetail() {
|
async function refreshUserDetail() {
|
||||||
await loadOrgs()
|
await loadAdminData()
|
||||||
if (selectedUser.value) {
|
if (selectedUser.value) {
|
||||||
try {
|
try {
|
||||||
userDetail.value = await apiJson(`/auth/api/admin/orgs/${selectedUser.value.org_uuid}/users/${selectedUser.value.uuid}`)
|
userDetail.value = await apiJson(`/auth/api/admin/users/${selectedUser.value.uuid}`)
|
||||||
} catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') }
|
} catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onUserNameSaved() {
|
|
||||||
await refreshUserDetail()
|
|
||||||
authStore.showMessage('User renamed', 'success', 1500)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitDialog() {
|
async function submitDialog() {
|
||||||
if (!dialog.value.type || dialog.value.busy) return
|
if (!dialog.value.type || dialog.value.busy) return
|
||||||
@@ -548,7 +751,7 @@ async function submitDialog() {
|
|||||||
apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } })
|
apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Organization "${name}" created.`, 'success', 2500)
|
authStore.showMessage(`Organization "${name}" created.`, 'success', 2500)
|
||||||
Promise.all([loadOrgs(), loadPermissions()])
|
loadAdminData()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to create organization', 'error')
|
authStore.showMessage(e.message || 'Failed to create organization', 'error')
|
||||||
@@ -559,10 +762,10 @@ async function submitDialog() {
|
|||||||
|
|
||||||
// Close dialog immediately, then perform async operation
|
// Close dialog immediately, then perform async operation
|
||||||
closeDialog()
|
closeDialog()
|
||||||
apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PUT', body: { display_name: name, permissions: org.permissions } })
|
apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Organization renamed to "${name}".`, 'success', 2500)
|
authStore.showMessage(`Organization renamed to "${name}".`, 'success', 2500)
|
||||||
loadOrgs()
|
loadAdminData()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to update organization', 'error')
|
authStore.showMessage(e.message || 'Failed to update organization', 'error')
|
||||||
@@ -576,7 +779,7 @@ async function submitDialog() {
|
|||||||
apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } })
|
apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Role "${name}" created.`, 'success', 2500)
|
authStore.showMessage(`Role "${name}" created.`, 'success', 2500)
|
||||||
loadOrgs()
|
loadAdminData()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to create role', 'error')
|
authStore.showMessage(e.message || 'Failed to create role', 'error')
|
||||||
@@ -587,10 +790,10 @@ async function submitDialog() {
|
|||||||
|
|
||||||
// Close dialog immediately, then perform async operation
|
// Close dialog immediately, then perform async operation
|
||||||
closeDialog()
|
closeDialog()
|
||||||
apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', body: { display_name: name, permissions: role.permissions } })
|
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
|
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
|
||||||
loadOrgs()
|
loadAdminData()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to update role', 'error')
|
authStore.showMessage(e.message || 'Failed to update role', 'error')
|
||||||
@@ -604,7 +807,7 @@ async function submitDialog() {
|
|||||||
apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } })
|
apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`User "${name}" added to ${role.display_name} role.`, 'success', 2500)
|
authStore.showMessage(`User "${name}" added to ${role.display_name} role.`, 'success', 2500)
|
||||||
loadOrgs()
|
loadAdminData()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to add user', 'error')
|
authStore.showMessage(e.message || 'Failed to add user', 'error')
|
||||||
@@ -615,10 +818,10 @@ async function submitDialog() {
|
|||||||
|
|
||||||
// Close dialog immediately, then perform async operation
|
// Close dialog immediately, then perform async operation
|
||||||
closeDialog()
|
closeDialog()
|
||||||
apiJson(`/auth/api/admin/orgs/${user.org_uuid}/users/${user.uuid}/display-name`, { method: 'PUT', body: { display_name: name } })
|
apiJson(`/auth/api/admin/users/${user.uuid}/info`, { method: 'PATCH', body: { display_name: name } })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
|
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
|
||||||
onUserNameSaved()
|
refreshUserDetail()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to update user name', 'error')
|
authStore.showMessage(e.message || 'Failed to update user name', 'error')
|
||||||
@@ -626,52 +829,110 @@ async function submitDialog() {
|
|||||||
return // Don't call closeDialog() again
|
return // Don't call closeDialog() again
|
||||||
} else if (t === 'perm-display') {
|
} else if (t === 'perm-display') {
|
||||||
const { permission } = dialog.value.data
|
const { permission } = dialog.value.data
|
||||||
const newId = dialog.value.data.id?.trim()
|
const newScope = dialog.value.data.scope?.trim()
|
||||||
const newDisplay = dialog.value.data.display_name?.trim()
|
const newDisplay = dialog.value.data.display_name?.trim()
|
||||||
|
const newDomain = dialog.value.data.domain?.trim() || ''
|
||||||
if (!newDisplay) throw new Error('Display name required')
|
if (!newDisplay) throw new Error('Display name required')
|
||||||
if (!newId) throw new Error('ID required')
|
if (!newScope) throw new Error('Scope required')
|
||||||
|
|
||||||
// Close dialog immediately, then perform async operation
|
// Close dialog immediately, then perform async operation
|
||||||
closeDialog()
|
closeDialog()
|
||||||
|
|
||||||
let apiCall;
|
const oldDomain = permission.domain || ''
|
||||||
if (newId !== permission.id) {
|
// Check if anything changed
|
||||||
// ID changed, use rename endpoint
|
if (newScope === permission.scope && newDisplay === permission.display_name && newDomain === oldDomain) {
|
||||||
apiCall = apiJson('/auth/api/admin/permission/rename', { method: 'POST', body: { old_id: permission.id, new_id: newId, display_name: newDisplay } })
|
return // No changes
|
||||||
} else if (newDisplay !== permission.display_name) {
|
|
||||||
// Only display name changed
|
|
||||||
const params = new URLSearchParams({ permission_id: permission.id, display_name: newDisplay })
|
|
||||||
apiCall = apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PUT' })
|
|
||||||
} else {
|
|
||||||
// No changes
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
apiCall
|
// Always use PATCH with permission_uuid
|
||||||
|
const params = new URLSearchParams({ permission_uuid: permission.uuid })
|
||||||
|
if (newScope !== permission.scope) params.set('scope', newScope)
|
||||||
|
if (newDisplay !== permission.display_name) params.set('display_name', newDisplay)
|
||||||
|
if (newDomain !== oldDomain) params.set('domain', newDomain || '')
|
||||||
|
|
||||||
|
apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Permission "${newDisplay}" updated.`, 'success', 2500)
|
authStore.showMessage(`Permission "${newDisplay}" updated.`, 'success', 2500)
|
||||||
loadPermissions()
|
loadAdminData()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to update permission', 'error')
|
authStore.showMessage(e.message || 'Failed to update permission', 'error')
|
||||||
})
|
})
|
||||||
return // Don't call closeDialog() again else if (t === 'perm-create') {
|
return // Don't call closeDialog() again
|
||||||
const id = dialog.value.data.id?.trim(); if (!id) throw new Error('ID required')
|
} else if (t === 'perm-create') {
|
||||||
|
const scope = dialog.value.data.scope?.trim(); if (!scope) throw new Error('Scope required')
|
||||||
const display_name = dialog.value.data.display_name?.trim(); if (!display_name) throw new Error('Display name required')
|
const display_name = dialog.value.data.display_name?.trim(); if (!display_name) throw new Error('Display name required')
|
||||||
|
const domain = dialog.value.data.domain?.trim() || ''
|
||||||
|
|
||||||
// Close dialog immediately, then perform async operation
|
// Close dialog immediately, then perform async operation
|
||||||
closeDialog()
|
closeDialog()
|
||||||
apiJson('/auth/api/admin/permissions', { method: 'POST', body: { id, display_name } })
|
apiJson('/auth/api/admin/permissions', { method: 'POST', body: { scope, display_name, domain: domain || undefined } })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Permission "${display_name}" created.`, 'success', 2500)
|
authStore.showMessage(`Permission "${display_name}" created.`, 'success', 2500)
|
||||||
loadPermissions()
|
loadAdminData()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to create permission', 'error')
|
authStore.showMessage(e.message || 'Failed to create permission', 'error')
|
||||||
})
|
})
|
||||||
return // Don't call closeDialog() again
|
return // Don't call closeDialog() again
|
||||||
|
} else if (t === 'oidc-edit') {
|
||||||
|
const { client_id, client_secret, isNew } = dialog.value.data
|
||||||
|
const name = dialog.value.data.name?.trim()
|
||||||
|
const uris = dialog.value.data.redirect_uris?.trim()
|
||||||
|
if (!name) throw new Error('Client name required')
|
||||||
|
|
||||||
|
const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : []
|
||||||
|
|
||||||
|
// Close dialog immediately, then perform async operation
|
||||||
|
closeDialog()
|
||||||
|
|
||||||
|
const req = client_secret
|
||||||
|
? sha256Hex(client_secret).then(secret_hash => isNew
|
||||||
|
? apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } })
|
||||||
|
: apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } }))
|
||||||
|
: apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } })
|
||||||
|
req
|
||||||
|
.then(() => {
|
||||||
|
authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500)
|
||||||
|
loadAdminData()
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
|
||||||
|
})
|
||||||
|
return // Don't call closeDialog() again
|
||||||
|
} else if (t === 'server-config') {
|
||||||
|
const rp_name = dialog.value.data.rp_name?.trim() || ''
|
||||||
|
const auth_host = dialog.value.data.auth_host?.trim() || ''
|
||||||
|
// Origins are stored as-is (hostnames); backend normalizes with https://
|
||||||
|
const origins = dialog.value.data.origins
|
||||||
|
.map(o => o.trim())
|
||||||
|
.filter(o => o)
|
||||||
|
|
||||||
|
closeDialog()
|
||||||
|
apiJson('/auth/api/admin/server-config', { method: 'PATCH', body: { rp_name, auth_host, origins } })
|
||||||
|
.then(() => {
|
||||||
|
authStore.showMessage('Server configuration updated.', 'success', 2500)
|
||||||
|
// Reload settings to reflect rp_name changes
|
||||||
|
authStore.loadSettings().then(() => {
|
||||||
|
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
authStore.showMessage(e.message || 'Failed to update server configuration', 'error')
|
||||||
|
})
|
||||||
|
return // Don't call closeDialog() again
|
||||||
} else if (t === 'confirm') {
|
} else if (t === 'confirm') {
|
||||||
const action = dialog.value.data.action; if (action) await action()
|
const action = dialog.value.data.action
|
||||||
|
// Close dialog first, then perform action (errors shown via showMessage)
|
||||||
|
closeDialog()
|
||||||
|
if (action) {
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
} catch (e) {
|
||||||
|
authStore.showMessage(e.message || 'Action failed', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return // Already closed
|
||||||
}
|
}
|
||||||
closeDialog()
|
closeDialog()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -685,26 +946,33 @@ async function submitDialog() {
|
|||||||
<StatusMessage />
|
<StatusMessage />
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<LoadingView v-if="loading" :message="loadingMessage" />
|
<LoadingView v-if="loading" :message="loadingMessage" />
|
||||||
<AuthRequiredMessage
|
<AccessDenied v-else-if="showBackMessage" />
|
||||||
v-else-if="showBackMessage"
|
<AccessDenied
|
||||||
@reload="reloadPage"
|
v-else-if="error"
|
||||||
|
icon="⚠️"
|
||||||
|
title="Error"
|
||||||
|
:message="error"
|
||||||
/>
|
/>
|
||||||
<section v-else-if="authenticated && (info?.is_global_admin || info?.is_org_admin)" class="view-root view-root--wide view-admin">
|
<AccessDenied
|
||||||
|
v-else-if="authenticated && !isMasterAdmin && !isOrgAdmin"
|
||||||
|
icon="⛔"
|
||||||
|
message="You do not have admin permissions for this application."
|
||||||
|
/>
|
||||||
|
<section v-else-if="authenticated && (isMasterAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
|
||||||
<header class="view-header">
|
<header class="view-header">
|
||||||
<h1>{{ pageHeading }}</h1>
|
|
||||||
<Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
<Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="section-block admin-section">
|
<section class="section-block admin-section">
|
||||||
<div class="section-body admin-section-body">
|
<div class="section-body admin-section-body">
|
||||||
<div v-if="error" class="surface surface--tight error">{{ error }}</div>
|
<div class="admin-panels">
|
||||||
<div v-else class="admin-panels">
|
|
||||||
<AdminOverview
|
<AdminOverview
|
||||||
v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)"
|
v-if="!selectedUser && !selectedOrg && !currentOidcId && (isMasterAdmin || isOrgAdmin)"
|
||||||
ref="adminOverviewRef"
|
ref="adminOverviewRef"
|
||||||
:info="info"
|
:info="info"
|
||||||
:orgs="orgs"
|
:orgs="orgs"
|
||||||
:permissions="permissions"
|
:permissions="permissions"
|
||||||
|
:oidc-clients="oidcClients"
|
||||||
:navigation-disabled="hasActiveModal"
|
:navigation-disabled="hasActiveModal"
|
||||||
:permission-summary="permissionSummary"
|
:permission-summary="permissionSummary"
|
||||||
@create-org="createOrg"
|
@create-org="createOrg"
|
||||||
@@ -715,6 +983,10 @@ async function submitDialog() {
|
|||||||
@open-dialog="openDialog"
|
@open-dialog="openDialog"
|
||||||
@delete-permission="deletePermission"
|
@delete-permission="deletePermission"
|
||||||
@rename-permission-display="renamePermissionDisplay"
|
@rename-permission-display="renamePermissionDisplay"
|
||||||
|
@create-oidc-client="createOidcClient"
|
||||||
|
@open-oidc-client="openOidcClient"
|
||||||
|
@delete-oidc-client="deleteOidcClient"
|
||||||
|
@open-server-config="openServerConfig"
|
||||||
@navigate-out="handlePanelNavigateOut"
|
@navigate-out="handlePanelNavigateOut"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -728,13 +1000,12 @@ async function submitDialog() {
|
|||||||
:show-reg-modal="showRegModal"
|
:show-reg-modal="showRegModal"
|
||||||
:navigation-disabled="hasActiveModal"
|
:navigation-disabled="hasActiveModal"
|
||||||
@generate-user-registration-link="generateUserRegistrationLink"
|
@generate-user-registration-link="generateUserRegistrationLink"
|
||||||
@go-overview="goOverview"
|
|
||||||
@open-org="openOrg"
|
@open-org="openOrg"
|
||||||
@on-user-name-saved="onUserNameSaved"
|
|
||||||
@refresh-user-detail="refreshUserDetail"
|
@refresh-user-detail="refreshUserDetail"
|
||||||
@edit-user-name="editUserName"
|
@edit-user-name="editUserName"
|
||||||
@close-reg-modal="showRegModal = false"
|
@close-reg-modal="showRegModal = false"
|
||||||
@navigate-out="handlePanelNavigateOut"
|
@navigate-out="handlePanelNavigateOut"
|
||||||
|
@delete-user="deleteUser(selectedUser, userDetail)"
|
||||||
/>
|
/>
|
||||||
<AdminOrgDetail
|
<AdminOrgDetail
|
||||||
v-else-if="selectedOrg"
|
v-else-if="selectedOrg"
|
||||||
@@ -749,10 +1020,23 @@ async function submitDialog() {
|
|||||||
@create-user-in-role="createUserInRole"
|
@create-user-in-role="createUserInRole"
|
||||||
@open-user="openUser"
|
@open-user="openUser"
|
||||||
@toggle-role-permission="toggleRolePermission"
|
@toggle-role-permission="toggleRolePermission"
|
||||||
@on-role-drag-over="onRoleDragOver"
|
@move-user-to-role="moveUserToRoleFromDrag"
|
||||||
|
@navigate-out="handlePanelNavigateOut"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminOidcDetail
|
||||||
|
v-else-if="currentOidcId && editingOidcClient"
|
||||||
|
ref="adminOidcDetailRef"
|
||||||
|
:client="editingOidcClient"
|
||||||
|
:permissions="permissions"
|
||||||
|
:is-new="editingOidcClient.isNew"
|
||||||
|
:navigation-disabled="hasActiveModal"
|
||||||
|
@save="handleOidcSave"
|
||||||
|
@cancel="handleOidcCancel"
|
||||||
|
@delete="deleteOidcClient"
|
||||||
|
@reset-secret="resetOidcSecret"
|
||||||
|
@create-permission="createPermissionForClient"
|
||||||
@navigate-out="handlePanelNavigateOut"
|
@navigate-out="handlePanelNavigateOut"
|
||||||
@on-role-drop="onRoleDrop"
|
|
||||||
@on-user-drag-start="onUserDragStart"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -763,16 +1047,17 @@ async function submitDialog() {
|
|||||||
<AdminDialogs
|
<AdminDialogs
|
||||||
:dialog="dialog"
|
:dialog="dialog"
|
||||||
:permission-id-pattern="PERMISSION_ID_PATTERN"
|
:permission-id-pattern="PERMISSION_ID_PATTERN"
|
||||||
|
:settings="authStore.settings"
|
||||||
@submit-dialog="submitDialog"
|
@submit-dialog="submitDialog"
|
||||||
@close-dialog="closeDialog"
|
@close-dialog="closeDialog"
|
||||||
|
@reset-oidc-secret="resetOidcSecret"
|
||||||
|
@create-permission-for-client="createPermissionForClient"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.view-admin { padding-bottom: var(--space-3xl); }
|
.view-admin { padding-bottom: var(--space-3xl); }
|
||||||
.view-header { display: flex; flex-direction: column; gap: var(--space-sm); }
|
|
||||||
.admin-section { margin-top: var(--space-xl); }
|
|
||||||
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
|
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
|
||||||
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
|
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Admin</title>
|
<title>Admin</title>
|
||||||
|
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
|
||||||
|
<link rel="stylesheet" href="/src/assets/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="admin-app"></div>
|
<div id="admin-app"></div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import '@/assets/style.css'
|
import { initThemeFromCache } from '@/utils/theme'
|
||||||
|
initThemeFromCache()
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Auth Profile</title>
|
<title>Auth Profile</title>
|
||||||
|
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
|
||||||
|
<link rel="stylesheet" href="/src/assets/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import '@/assets/style.css'
|
import { initThemeFromCache } from '@/utils/theme'
|
||||||
|
initThemeFromCache()
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
|||||||
@@ -2,19 +2,23 @@
|
|||||||
<RestrictedAuth
|
<RestrictedAuth
|
||||||
:mode="authMode"
|
:mode="authMode"
|
||||||
:remote-auth-token="remoteAuthToken"
|
:remote-auth-token="remoteAuthToken"
|
||||||
|
:oidc-query-string="oidcQueryString"
|
||||||
@authenticated="handleAuthenticated"
|
@authenticated="handleAuthenticated"
|
||||||
@back="handleBack"
|
@back="handleBack"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
||||||
|
|
||||||
// Check if this is a remote auth URL: /auth/{token}
|
// Check if this is a remote auth URL: /auth/{token}
|
||||||
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
|
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
|
||||||
const remoteAuthToken = ref(null)
|
const remoteAuthToken = ref(null)
|
||||||
|
|
||||||
|
// For OIDC flow, pass the raw query string to preserve exact param values
|
||||||
|
const oidcQueryString = window.location.search.includes('client_id=') ? window.location.search : null
|
||||||
|
|
||||||
function extractRemoteToken() {
|
function extractRemoteToken() {
|
||||||
const path = window.location.pathname
|
const path = window.location.pathname
|
||||||
// Match /auth/{token} where token is a passphrase with dots
|
// Match /auth/{token} where token is a passphrase with dots
|
||||||
@@ -30,14 +34,20 @@ function extractRemoteToken() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect mode from URL hash fragment
|
// Parse URL hash fragment
|
||||||
const authMode = computed(() => {
|
const hashParams = new URLSearchParams(window.location.hash.slice(1))
|
||||||
const params = new URLSearchParams(window.location.hash.slice(1))
|
|
||||||
const mode = params.get('mode')
|
// Determine auth mode based on URL path
|
||||||
if (mode === 'reauth') return 'reauth'
|
// - /auth/restricted/oidc: OIDC flow, no session dependency
|
||||||
if (mode === 'forbidden') return 'forbidden'
|
// - /auth/restricted/iframe: iframe embedding, mode from hash params
|
||||||
return 'login'
|
// - Other paths: forward auth, mode from hash params
|
||||||
})
|
let authMode
|
||||||
|
if (window.location.pathname === '/auth/restricted/oidc') {
|
||||||
|
authMode = 'oidc'
|
||||||
|
} else {
|
||||||
|
// Both iframe and forward auth use hash params for mode (forbidden/login/reauth)
|
||||||
|
authMode = ['reauth', 'forbidden'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
|
||||||
|
}
|
||||||
|
|
||||||
function postToParent(message) {
|
function postToParent(message) {
|
||||||
if (window.parent && window.parent !== window) {
|
if (window.parent && window.parent !== window) {
|
||||||
@@ -46,10 +56,15 @@ function postToParent(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleAuthenticated(result) {
|
function handleAuthenticated(result) {
|
||||||
|
if (result.redirect_url) {
|
||||||
|
// OIDC flow: redirect to client with auth code
|
||||||
|
window.location.href = result.redirect_url
|
||||||
|
return
|
||||||
|
}
|
||||||
postToParent({
|
postToParent({
|
||||||
type: 'auth-success',
|
type: 'auth-success',
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
sessionToken: result.session_token
|
exchangeCode: result.exchange_code
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" style="background: transparent">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<script>{let t=localStorage.getItem('paskia-theme');if(!t){let p=new URLSearchParams(location.hash.slice(1)).get('theme');if(p==='light'||p==='dark')t=p}(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark');if(window.location.pathname==='/auth/restricted/iframe')document.documentElement.style.background='transparent'}</script>
|
||||||
|
<link rel="stylesheet" href="/src/assets/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import './theme.js'
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import RestrictedApi from './RestrictedApi.vue'
|
import RestrictedApi from './RestrictedApi.vue'
|
||||||
import '@/assets/style.css'
|
|
||||||
import { initKeyboardNavigation } from '@/utils/keynav'
|
import { initKeyboardNavigation } from '@/utils/keynav'
|
||||||
|
|
||||||
createApp(RestrictedApi).mount('#app')
|
createApp(RestrictedApi).mount('#app')
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Early theme for restricted app - user preference (localStorage) wins, then URL param
|
||||||
|
import { applyTheme, getCachedTheme } from '@/utils/theme.js'
|
||||||
|
|
||||||
|
function getTheme() {
|
||||||
|
const params = new URLSearchParams(location.hash.slice(1))
|
||||||
|
return getCachedTheme() || params.get('theme') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply theme class to document root
|
||||||
|
applyTheme(getTheme())
|
||||||
|
addEventListener('hashchange', () => applyTheme(getTheme()))
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Access Restricted</title>
|
<title>Access Restricted</title>
|
||||||
|
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<div v-if="status.show" class="global-status" style="display: block;">
|
<div v-if="status.show" class="global-status show">
|
||||||
<div :class="['status', status.type]">
|
<div :class="['status', status.type]">
|
||||||
{{ status.message }}
|
{{ status.message }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="view-root">
|
<main class="view-root">
|
||||||
<div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;">
|
<div class="surface surface--tight reset-container">
|
||||||
<header class="view-header" style="text-align: center;">
|
<header class="view-header center">
|
||||||
<h1>🔑 Registration</h1>
|
<h1>🔑 Registration</h1>
|
||||||
<p class="view-lede">
|
<p class="view-lede">
|
||||||
{{ subtitleMessage }}
|
{{ subtitleMessage }}
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
<section class="section-block" v-else-if="!canRegister">
|
<section class="section-block" v-else-if="!canRegister">
|
||||||
<div class="section-body center">
|
<div class="section-body center">
|
||||||
<div class="button-row center" style="justify-content: center;">
|
<div class="button-row button-row--center">
|
||||||
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
|
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,7 +59,8 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import passkey from '@/utils/passkey'
|
import passkey from '@/utils/passkey'
|
||||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||||
import { apiJson, ApiError, getUserFriendlyErrorMessage } from '@/utils/api'
|
import { apiJson, ApiError, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
|
||||||
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
|
|
||||||
const status = reactive({
|
const status = reactive({
|
||||||
show: false,
|
show: false,
|
||||||
@@ -71,21 +72,21 @@ const initializing = ref(true)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const token = ref('')
|
const token = ref('')
|
||||||
const settings = ref(null)
|
const settings = ref(null)
|
||||||
const userInfo = ref(null)
|
const tokenInfo = ref(null)
|
||||||
const displayName = ref('')
|
const displayName = ref('')
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
let statusTimer = null
|
let statusTimer = null
|
||||||
|
|
||||||
const sessionDescriptor = computed(() => userInfo.value?.session_type || 'your enrollment')
|
const sessionDescriptor = computed(() => tokenInfo.value?.token_type || 'your enrollment')
|
||||||
const subtitleMessage = computed(() => {
|
const subtitleMessage = computed(() => {
|
||||||
if (initializing.value) return 'Preparing your secure enrollment…'
|
if (initializing.value) return 'Preparing your secure enrollment…'
|
||||||
if (!canRegister.value) return 'This authentication link is no longer valid.'
|
if (!canRegister.value) return 'This authentication link is no longer valid.'
|
||||||
return `Finish up ${sessionDescriptor.value}. You may edit the name below if needed, and it will be saved to your passkey.`
|
return `Finish up ${sessionDescriptor.value}. The name entered will be stored on your passkey and on our system.`
|
||||||
})
|
})
|
||||||
|
|
||||||
const basePath = computed(() => uiBasePath())
|
const basePath = computed(() => uiBasePath())
|
||||||
|
|
||||||
const canRegister = computed(() => !!(token.value && userInfo.value))
|
const canRegister = computed(() => !!(token.value && tokenInfo.value))
|
||||||
|
|
||||||
function showMessage(message, type = 'info', duration = 3000) {
|
function showMessage(message, type = 'info', duration = 3000) {
|
||||||
status.show = true
|
status.show = true
|
||||||
@@ -109,15 +110,17 @@ async function fetchSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUserInfo() {
|
async function fetchTokenInfo() {
|
||||||
if (!token.value) return
|
if (!token.value) return
|
||||||
try {
|
try {
|
||||||
userInfo.value = await apiJson(`/auth/api/user-info?reset=${encodeURIComponent(token.value)}`, {
|
tokenInfo.value = await apiJson('/auth/api/token-info', {
|
||||||
method: 'POST'
|
method: 'GET',
|
||||||
|
headers: { 'Authorization': `Bearer ${token.value}` },
|
||||||
})
|
})
|
||||||
displayName.value = userInfo.value?.user?.user_name || ''
|
displayName.value = tokenInfo.value.display_name
|
||||||
|
if (tokenInfo.value.theme) updateThemeFromSession({ user: { theme: tokenInfo.value.theme } })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load user info', error)
|
console.error('Failed to load token info', error)
|
||||||
const message = error instanceof ApiError
|
const message = error instanceof ApiError
|
||||||
? (error.data?.detail || 'The authentication link is invalid or expired.')
|
? (error.data?.detail || 'The authentication link is invalid or expired.')
|
||||||
: getUserFriendlyErrorMessage(error)
|
: getUserFriendlyErrorMessage(error)
|
||||||
@@ -143,7 +146,7 @@ async function registerPasskey() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setSessionCookie(result)
|
await exchangeCode(result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
const message = error?.message || 'Failed to establish session'
|
const message = error?.message || 'Failed to establish session'
|
||||||
@@ -155,15 +158,14 @@ async function registerPasskey() {
|
|||||||
setTimeout(() => { loading.value = false; goHome() }, 800)
|
setTimeout(() => { loading.value = false; goHome() }, 800)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setSessionCookie(result) {
|
async function exchangeCode(result) {
|
||||||
if (!result?.session_token) {
|
if (!result?.exchange_code) {
|
||||||
throw new Error('Registration response missing session_token')
|
throw new Error('Registration response missing exchange_code')
|
||||||
}
|
}
|
||||||
return await apiJson('/auth/api/set-session', {
|
return await apiJson('/auth/api/set-session', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Authorization': `Bearer ${result.exchange_code}` },
|
||||||
Authorization: `Bearer ${result.session_token}`
|
timeout: paskiaSettings.auth_ms,
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,19 +198,20 @@ onMounted(async () => {
|
|||||||
initializing.value = false
|
initializing.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await fetchUserInfo()
|
await fetchTokenInfo()
|
||||||
initializing.value = false
|
initializing.value = false
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.center {
|
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||||
text-align: center;
|
.reset-container {
|
||||||
}
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
.button-row.center {
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
|
gap: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-body {
|
.section-body {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Complete Passkey Setup</title>
|
<title>Complete Passkey Setup</title>
|
||||||
|
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -10,10 +10,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@simplewebauthn/browser": "^13.1.2",
|
"@simplewebauthn/browser": "^13.1.2",
|
||||||
|
"paskia": "file:../paskia-js",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sirv": "^3.0.2",
|
"sirv": "^3.0.2",
|
||||||
"vue": "^3.5.17"
|
"uuidv7": "^1.1.0",
|
||||||
|
"vue": "^3.5.17",
|
||||||
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.0",
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -1,15 +1,173 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
import NameEditForm from '@/components/NameEditForm.vue'
|
import NameEditForm from '@/components/NameEditForm.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
dialog: Object,
|
dialog: Object,
|
||||||
PERMISSION_ID_PATTERN: String
|
PERMISSION_ID_PATTERN: String,
|
||||||
|
settings: Object
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['submitDialog', 'closeDialog'])
|
const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient'])
|
||||||
|
|
||||||
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
|
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
|
||||||
|
const NO_SUBMIT_TYPES = new Set([])
|
||||||
|
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
||||||
|
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
|
||||||
|
|
||||||
|
// Initialize validation properties
|
||||||
|
if (props.dialog?.data && props.dialog.type === 'server-config') {
|
||||||
|
if (!('authHostValidation' in props.dialog.data)) {
|
||||||
|
props.dialog.data.authHostValidation = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidationInvalid = computed(() => {
|
||||||
|
if (props.dialog?.type !== 'server-config') return false
|
||||||
|
const d = props.dialog.data
|
||||||
|
if (d.authHostValidation?.startsWith('invalid') || d.authHostValidation === 'validating') return true
|
||||||
|
if (d.originValidation?.some(v => v === 'invalid' || v === 'validating')) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Copy-to-clipboard helper
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
function copyText(value, label) {
|
||||||
|
navigator.clipboard.writeText(value).then(() => {
|
||||||
|
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOrigin() {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (d) {
|
||||||
|
d.origins.push(rpId.value)
|
||||||
|
d.originValidation.push(null)
|
||||||
|
validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function removeOrigin(i) {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (d) {
|
||||||
|
d.origins.splice(i, 1)
|
||||||
|
d.originValidation.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function stripScheme(val, i) {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (d) d.origins[i] = val.replace(/^https:\/\//, '').replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
function stripSchemeAuthHost() {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (d && d.auth_host) d.auth_host = d.auth_host.replace(/^https:\/\//, '').replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
function focusOriginStart(e) {
|
||||||
|
e.target.setSelectionRange(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOriginDomain(origin, rpId) {
|
||||||
|
if (!origin.trim()) return false
|
||||||
|
try {
|
||||||
|
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
|
||||||
|
const hostname = url.hostname
|
||||||
|
return hostname === rpId || hostname.endsWith('.' + rpId)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateOriginConnectivity(origin, i) {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (!d) return
|
||||||
|
|
||||||
|
d.originValidation[i] = 'validating'
|
||||||
|
try {
|
||||||
|
const cleanOrigin = origin.replace(/\/+$/, '')
|
||||||
|
const testUrl = cleanOrigin.startsWith('http') ? cleanOrigin : 'https://' + cleanOrigin
|
||||||
|
const response = await fetch(testUrl + '/auth/api/settings', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Accept': 'application/json' }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
// Check if it returns valid settings (has rp_id and matches current rp_id)
|
||||||
|
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
|
||||||
|
// Only update if the origin hasn't changed
|
||||||
|
if (d.origins[i] === origin) {
|
||||||
|
d.originValidation[i] = result
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (d.origins[i] === origin) {
|
||||||
|
d.originValidation[i] = 'invalid'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (d.origins[i] === origin) {
|
||||||
|
d.originValidation[i] = 'invalid'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOrigin(origin, i) {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (!d) return
|
||||||
|
|
||||||
|
const id = rpId.value
|
||||||
|
if (validateOriginDomain(origin, id)) {
|
||||||
|
validateOriginConnectivity(origin, i)
|
||||||
|
} else {
|
||||||
|
d.originValidation[i] = 'invalid'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateAuthHostConnectivity(authHost) {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (!d) return
|
||||||
|
|
||||||
|
d.authHostValidation = 'validating'
|
||||||
|
try {
|
||||||
|
const cleanAuthHost = authHost.replace(/\/+$/, '')
|
||||||
|
const testUrl = cleanAuthHost.startsWith('http') ? cleanAuthHost : 'https://' + cleanAuthHost
|
||||||
|
const response = await fetch(testUrl + '/auth/api/settings', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Accept': 'application/json' }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
// Check if it returns valid settings (has rp_id and matches current rp_id)
|
||||||
|
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
|
||||||
|
// Only update if the auth_host hasn't changed
|
||||||
|
if (d.auth_host === authHost) {
|
||||||
|
d.authHostValidation = result
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (d.auth_host === authHost) {
|
||||||
|
d.authHostValidation = 'invalid-connectivity'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (d.auth_host === authHost) {
|
||||||
|
d.authHostValidation = 'invalid-connectivity'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAuthHost() {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (!d || !d.auth_host?.trim()) {
|
||||||
|
d.authHostValidation = null // Allow empty
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = rpId.value
|
||||||
|
if (validateOriginDomain(d.auth_host, id)) {
|
||||||
|
validateAuthHostConnectivity(d.auth_host)
|
||||||
|
} else {
|
||||||
|
d.authHostValidation = 'invalid-domain'
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -21,7 +179,9 @@ const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'
|
|||||||
<template v-else-if="dialog.type==='role-update'">Edit Role</template>
|
<template v-else-if="dialog.type==='role-update'">Edit Role</template>
|
||||||
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
|
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
|
||||||
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
|
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
|
||||||
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission Display' }}</template>
|
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
|
||||||
|
<template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
|
||||||
|
<template v-else-if="dialog.type==='server-config'">Server Options</template>
|
||||||
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||||||
</h3>
|
</h3>
|
||||||
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
||||||
@@ -72,16 +232,52 @@ const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'
|
|||||||
<label>Display Name
|
<label>Display Name
|
||||||
<input ref="displayNameInput" v-model="dialog.data.display_name" required />
|
<input ref="displayNameInput" v-model="dialog.data.display_name" required />
|
||||||
</label>
|
</label>
|
||||||
<label>Permission ID
|
<label>Permission Scope
|
||||||
<input v-model="dialog.data.id" :placeholder="dialog.type === 'perm-create' ? 'yourapp:permission' : dialog.data.permission.id" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
|
<input v-model="dialog.data.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
|
||||||
</label>
|
</label>
|
||||||
<p class="small muted">The permission ID is used for permission checks in the application. Changing it may break deployed applications that reference this permission.</p>
|
<p class="small muted">E.g. yourapp:reports. Changing the scope name may break deployed applications.</p>
|
||||||
|
<label>Domain Scope
|
||||||
|
<input v-model="dialog.data.domain" data-form-type="other" />
|
||||||
|
</label>
|
||||||
|
<p class="small muted">A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="dialog.type==='server-config'">
|
||||||
|
<label>Site Branding (rp-name)
|
||||||
|
<input v-model="dialog.data.rp_name" :placeholder="rpId" />
|
||||||
|
</label>
|
||||||
|
<label>Dedicated Authentication Site (auth-host)
|
||||||
|
<input v-model="dialog.data.auth_host" @input="validateAuthHost()" :class="{ 'input-error': dialog.data.authHostValidation?.startsWith('invalid') }" />
|
||||||
|
</label>
|
||||||
|
<p v-if="dialog.data.authHostValidation === 'validating'" class="small muted">Validating...</p>
|
||||||
|
<p v-else-if="dialog.data.authHostValidation === 'valid'" class="small muted">Valid</p>
|
||||||
|
<p v-else-if="dialog.data.authHostValidation === 'invalid-domain'" class="small muted">Invalid domain</p>
|
||||||
|
<p v-else-if="dialog.data.authHostValidation === 'invalid-connectivity'" class="small muted">Well-formed but unreachable</p>
|
||||||
|
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Invalid configuration</p>
|
||||||
|
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Enter {{ rpId }} or any subdomain of it.</p>
|
||||||
|
<div class="origin-label">
|
||||||
|
Allowed Origins
|
||||||
|
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin" aria-label="Add origin" title="Add origin">➕</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="dialog.data.origins.length" class="origin-list">
|
||||||
|
<div v-for="(_, i) in dialog.data.origins" :key="i" class="origin-row">
|
||||||
|
<input
|
||||||
|
:value="dialog.data.origins[i]"
|
||||||
|
@input="e => { dialog.data.origins[i] = e.target.value; validateOrigin(e.target.value, i) }"
|
||||||
|
@focus="focusOriginStart"
|
||||||
|
class="origin-input"
|
||||||
|
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
|
||||||
|
/>
|
||||||
|
<button type="button" class="icon-btn delete-icon" @click="removeOrigin(i)" aria-label="Remove origin" title="Remove origin">❌</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="!dialog.data.origins.length" class="small muted">{{ rpId }} and all subdomains allowed.</p>
|
||||||
|
<p v-else class="small muted">Only the above sites are allowed to authenticate.</p>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="dialog.type==='confirm'">
|
<template v-else-if="dialog.type==='confirm'">
|
||||||
<p>{{ dialog.data.message }}</p>
|
<p>{{ dialog.data.message }}</p>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
|
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
|
||||||
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions">
|
<div v-if="!NAME_EDIT_TYPES.has(dialog.type) && !NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
@@ -93,17 +289,46 @@ const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
:disabled="dialog.busy"
|
:disabled="dialog.busy || isValidationInvalid"
|
||||||
>
|
>
|
||||||
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
|
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
@click="$emit('closeDialog')"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.error { color: var(--color-danger-text); }
|
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
|
||||||
.small { font-size: 0.9rem; }
|
.oidc-divider { border: none; border-top: 1px solid var(--color-border); margin: var(--space-sm) 0; }
|
||||||
.muted { color: var(--color-text-muted); }
|
.oidc-dl { display: grid; grid-template-columns: auto 1fr; gap: 0.2rem 1rem; align-items: baseline; margin: 0; }
|
||||||
|
.oidc-dl dt { font-size: 0.85rem; color: var(--color-text-muted); white-space: nowrap; }
|
||||||
|
.oidc-dl dd { margin: 0; cursor: pointer; overflow: hidden; }
|
||||||
|
.oidc-dl output { font-family: var(--font-mono, monospace); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
||||||
|
.oidc-reset-row { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; }
|
||||||
|
.oidc-groups { cursor: default; }
|
||||||
|
.oidc-group { cursor: pointer; }
|
||||||
|
.oidc-group output { white-space: normal; word-break: break-all; }
|
||||||
|
|
||||||
|
/* Server config origins */
|
||||||
|
.origin-label { font-weight: 600; font-size: 0.95rem; margin-top: var(--space-sm); display: flex; align-items: center; gap: var(--space-sm); }
|
||||||
|
.origin-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.origin-row { display: flex; align-items: center; gap: var(--space-xs); }
|
||||||
|
.origin-input { flex: 1; min-width: 8rem; font-family: var(--font-mono, monospace); }
|
||||||
|
.origin-row .delete-icon { flex-shrink: 0; }
|
||||||
|
.origin-add-btn { font-size: 1.2rem; }
|
||||||
|
|
||||||
|
.input-error {
|
||||||
|
border-color: var(--color-error);
|
||||||
|
background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import { getDirection, navigateButtonRow } from '@/utils/keynav'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
client: Object,
|
||||||
|
permissions: Array,
|
||||||
|
isNew: { type: Boolean, default: false },
|
||||||
|
navigationDisabled: { type: Boolean, default: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['save', 'cancel', 'delete', 'resetSecret', 'createPermission', 'navigateOut'])
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const headerRef = ref(null)
|
||||||
|
|
||||||
|
// Helper function to build URLs
|
||||||
|
function authSitePath(path) {
|
||||||
|
const url = new URL(authStore.settings.auth_site_url)
|
||||||
|
url.pathname = path
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local form state
|
||||||
|
const name = ref('')
|
||||||
|
const redirectUris = ref('')
|
||||||
|
const clientSecret = ref(null)
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const clientId = computed(() => props.client?.client_id || props.client?.uuid || '')
|
||||||
|
const discoveryUrl = computed(() => authSitePath('/.well-known/openid-configuration'))
|
||||||
|
const iconUrl = computed(() => authSitePath('/favicon.ico'))
|
||||||
|
|
||||||
|
// Groups (permissions) scoped to this client
|
||||||
|
const clientGroups = computed(() => {
|
||||||
|
if (!props.client || !props.permissions) return []
|
||||||
|
const clientUuid = props.client.uuid || props.client.client_id
|
||||||
|
return props.permissions.filter(p => p.domain === clientUuid).sort((a, b) => a.scope.localeCompare(b.scope))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize form data from props
|
||||||
|
watch(() => props.client, (c) => {
|
||||||
|
if (c) {
|
||||||
|
name.value = c.name || ''
|
||||||
|
redirectUris.value = Array.isArray(c.redirect_uris) ? c.redirect_uris.join('\n') : (c.redirect_uris || '')
|
||||||
|
clientSecret.value = c.client_secret || null
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Copy-to-clipboard helper
|
||||||
|
function copyText(value, label) {
|
||||||
|
navigator.clipboard.writeText(value).then(() => {
|
||||||
|
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResetSecret() {
|
||||||
|
emit('resetSecret', clientId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When parent resets secret, update local state
|
||||||
|
watch(() => props.client?.client_secret, (newSecret) => {
|
||||||
|
if (newSecret) {
|
||||||
|
clientSecret.value = newSecret
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const trimmedName = name.value.trim()
|
||||||
|
if (!trimmedName) {
|
||||||
|
authStore.showMessage('Client name is required', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const uris = redirectUris.value.trim()
|
||||||
|
const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : []
|
||||||
|
|
||||||
|
emit('save', {
|
||||||
|
client_id: clientId.value,
|
||||||
|
client_secret: clientSecret.value,
|
||||||
|
name: trimmedName,
|
||||||
|
redirect_uris,
|
||||||
|
isNew: props.isNew
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
emit('delete', props.client)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreatePermission() {
|
||||||
|
emit('createPermission', clientId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit('cancel')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
function handleHeaderKeydown(event) {
|
||||||
|
if (props.navigationDisabled) return
|
||||||
|
|
||||||
|
const direction = getDirection(event)
|
||||||
|
if (!direction) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (direction === 'left' || direction === 'right') {
|
||||||
|
navigateButtonRow(headerRef.value, event.target, direction, { itemSelector: 'button, a' })
|
||||||
|
} else if (direction === 'up') {
|
||||||
|
emit('navigateOut', 'up')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusFirstElement() {
|
||||||
|
const firstFocusable = headerRef.value?.querySelector('button, a, input')
|
||||||
|
if (firstFocusable) firstFocusable.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ focusFirstElement })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="oidc-detail">
|
||||||
|
<form @submit.prevent="handleSave" class="oidc-form">
|
||||||
|
<!-- Client credentials section -->
|
||||||
|
<section class="section-block">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Client Configuration</h2>
|
||||||
|
<p class="section-description">Configure these values in the client application.</p>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
|
||||||
|
<dl class="oidc-dl">
|
||||||
|
<dt>Authentication Name</dt>
|
||||||
|
<dd>
|
||||||
|
<output @click="copyText(authStore.settings.rp_name, 'Authentication Name')" title="Click to copy">{{ authStore.settings.rp_name }}</output>
|
||||||
|
<span class="small muted"> (Login With, may affect URLs – optional)</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Client ID</dt>
|
||||||
|
<dd><output @click="copyText(clientId, 'Client ID')" title="Click to copy">{{ clientId }}</output></dd>
|
||||||
|
|
||||||
|
<dt>Client Secret <button v-if="!clientSecret" type="button" class="icon-btn" @click="handleResetSecret" title="Revoke and re-generate secret">🔄</button></dt>
|
||||||
|
<dd>
|
||||||
|
<output v-if="clientSecret" @click="copyText(clientSecret, 'Client Secret')" title="Click to copy">{{ clientSecret }}</output>
|
||||||
|
<span v-else class="small muted">(only stored in hashed form)</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Auto Discovery URL</dt>
|
||||||
|
<dd><output @click="copyText(discoveryUrl, 'OpenID Connect Auto Discovery URL')" title="Click to copy">{{ discoveryUrl }}</output></dd>
|
||||||
|
|
||||||
|
<dt>Icon URL</dt>
|
||||||
|
<dd>
|
||||||
|
<output @click="copyText(iconUrl, 'Icon URL')" title="Click to copy">{{ iconUrl }}</output>
|
||||||
|
<span class="small muted"> (optional)</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
|
||||||
|
<template v-if="clientGroups.length">
|
||||||
|
<dt>Groups Claim Name</dt>
|
||||||
|
<dd>
|
||||||
|
<output @click="copyText('groups', 'Groups Claim Name')" title="Click to copy">groups</output>
|
||||||
|
</dd>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<dt>Groups <button type="button" class="icon-btn" @click="handleCreatePermission" title="Add permission scoped to this client">➕</button></dt>
|
||||||
|
<dd class="oidc-groups">
|
||||||
|
<template v-if="clientGroups.length">
|
||||||
|
<output v-for="group in clientGroups" :key="group.uuid" class="oidc-group" @click="copyText(group.scope, 'Group Value')" :title="group.display_name">{{ group.scope }}</output>
|
||||||
|
</template>
|
||||||
|
<span v-else class="small muted">(no permissions defined)</span>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<span class="warning-text">
|
||||||
|
<strong v-if="clientSecret">⚠️ {{ isNew ? 'Save the secret now — it cannot be retrieved later.' : 'Saving will prevent access with the old secret.' }}</strong>
|
||||||
|
<span v-else>ℹ️ The client may use groups to check for required permissions.</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Editable fields -->
|
||||||
|
<section class="section-block">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Paskia Configuration</h2>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<label>Client Name
|
||||||
|
<input v-model="name" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Redirect URIs
|
||||||
|
<p class="small muted">This should be provided by the client application.</p>
|
||||||
|
<textarea v-model="redirectUris" placeholder="(autodiscover one on first use)" rows="3"></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="oidc-actions">
|
||||||
|
<button type="button" class="btn-secondary" @click="handleCancel">Cancel</button>
|
||||||
|
<button v-if="!isNew" type="button" class="btn-danger" @click="handleDelete">Delete Client</button>
|
||||||
|
<button type="submit" class="btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.oidc-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oidc-header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oidc-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oidc-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oidc-dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.3rem 1rem;
|
||||||
|
align-items: baseline;
|
||||||
|
margin: var(--space-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oidc-dl dt {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oidc-dl dd {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oidc-dl output {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-text {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-height: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oidc-group { display: block; }
|
||||||
|
.oidc-group { white-space: normal; word-break: break-all; }
|
||||||
|
|
||||||
|
.section-body label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oidc-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--space-md);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
|
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||||
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
|
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -8,7 +10,7 @@ const props = defineProps({
|
|||||||
navigationDisabled: { type: Boolean, default: false }
|
navigationDisabled: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'onRoleDragOver', 'onRoleDrop', 'onUserDragStart', 'navigateOut'])
|
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'moveUserToRole', 'navigateOut'])
|
||||||
|
|
||||||
// Template refs for navigation
|
// Template refs for navigation
|
||||||
const orgTitleRef = ref(null)
|
const orgTitleRef = ref(null)
|
||||||
@@ -16,7 +18,8 @@ const permMatrixRef = ref(null)
|
|||||||
const rolesGridRef = ref(null)
|
const rolesGridRef = ref(null)
|
||||||
|
|
||||||
const sortedRoles = computed(() => {
|
const sortedRoles = computed(() => {
|
||||||
return [...props.selectedOrg.roles].sort((a, b) => {
|
// o.roles is dict[UUID, Role], convert to array for sorting with uuid added
|
||||||
|
return Object.entries(props.selectedOrg.roles).map(([uuid, r]) => ({ uuid, ...r })).sort((a, b) => {
|
||||||
const nameA = a.display_name.toLowerCase()
|
const nameA = a.display_name.toLowerCase()
|
||||||
const nameB = b.display_name.toLowerCase()
|
const nameB = b.display_name.toLowerCase()
|
||||||
if (nameA !== nameB) {
|
if (nameA !== nameB) {
|
||||||
@@ -26,8 +29,47 @@ const sortedRoles = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function permissionDisplayName(id) {
|
// Get org's grantable permissions as full permission objects (with UUIDs)
|
||||||
return props.permissions.find(p => p.id === id)?.display_name || id
|
const orgPermissions = computed(() => {
|
||||||
|
// props.selectedOrg.permissions is dict[UUID, Permission]
|
||||||
|
const uuidSet = new Set(Object.keys(props.selectedOrg.permissions || {}))
|
||||||
|
return props.permissions.filter(p => uuidSet.has(p.uuid))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get users for a role as sorted array of { uuid, ...user }
|
||||||
|
function getNormalizedName(name) {
|
||||||
|
let cleaned = name.replace(/\([^)]*\)/g, '').trim();
|
||||||
|
if (cleaned.includes(',')) {
|
||||||
|
return cleaned.toLowerCase();
|
||||||
|
} else {
|
||||||
|
const parts = cleaned.split(/\s+/);
|
||||||
|
const last = parts.pop();
|
||||||
|
const first = parts.join(' ');
|
||||||
|
return `${last}, ${first}`.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleUsers(roleUuid) {
|
||||||
|
return Object.entries(props.selectedOrg.users)
|
||||||
|
.filter(([_, u]) => u.role === roleUuid)
|
||||||
|
.map(([uuid, u]) => ({ uuid, ...u }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const normA = getNormalizedName(a.display_name);
|
||||||
|
const normB = getNormalizedName(b.display_name);
|
||||||
|
return normA.localeCompare(normB);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleUserCount(roleUuid) {
|
||||||
|
return Object.values(props.selectedOrg.users).filter(u => u.role === roleUuid).length
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUserChange(evt, targetRoleUuid) {
|
||||||
|
// Only handle 'added' events (when a user is dropped into this role)
|
||||||
|
if (evt.added) {
|
||||||
|
const userUuid = evt.added.element.uuid
|
||||||
|
emit('moveUserToRole', userUuid, targetRoleUuid)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRolePermission(role, pid, checked) {
|
function toggleRolePermission(role, pid, checked) {
|
||||||
@@ -77,7 +119,7 @@ function handleMatrixKeydown(event) {
|
|||||||
|
|
||||||
// Calculate grid dimensions
|
// Calculate grid dimensions
|
||||||
const cols = sortedRoles.value.length
|
const cols = sortedRoles.value.length
|
||||||
const rows = props.selectedOrg.permissions.length
|
const rows = Object.keys(props.selectedOrg.permissions).length
|
||||||
|
|
||||||
const currentRow = Math.floor(currentIndex / cols)
|
const currentRow = Math.floor(currentIndex / cols)
|
||||||
const currentCol = currentIndex % cols
|
const currentCol = currentIndex % cols
|
||||||
@@ -213,7 +255,7 @@ function handleRoleHeaderKeydown(event, roleIndex) {
|
|||||||
if (checkboxes?.length) {
|
if (checkboxes?.length) {
|
||||||
// Focus the checkbox in the corresponding column
|
// Focus the checkbox in the corresponding column
|
||||||
const cols = sortedRoles.value.length
|
const cols = sortedRoles.value.length
|
||||||
const rows = props.selectedOrg.permissions.length
|
const rows = Object.keys(props.selectedOrg.permissions).length
|
||||||
const targetIndex = (rows - 1) * cols + roleIndex
|
const targetIndex = (rows - 1) * cols + roleIndex
|
||||||
if (checkboxes[targetIndex]) checkboxes[targetIndex].focus()
|
if (checkboxes[targetIndex]) checkboxes[targetIndex].focus()
|
||||||
else checkboxes[checkboxes.length - 1].focus()
|
else checkboxes[checkboxes.length - 1].focus()
|
||||||
@@ -281,7 +323,7 @@ defineExpose({ focusFirstElement })
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h2 class="org-title" ref="orgTitleRef" @keydown="handleTitleKeydown" :title="selectedOrg.uuid">
|
<h2 class="org-title" ref="orgTitleRef" @keydown="handleTitleKeydown" :title="selectedOrg.uuid">
|
||||||
<span class="org-name">{{ selectedOrg.display_name }}</span>
|
<span class="org-name">{{ selectedOrg.org.display_name }}</span>
|
||||||
<button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
<button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -302,17 +344,17 @@ defineExpose({ focusFirstElement })
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid-head role-head add-role-head" title="Add role" @click="$emit('createRole', selectedOrg)" role="button" tabindex="0" @keydown.enter="$emit('createRole', selectedOrg)">➕</div>
|
<div class="grid-head role-head add-role-head" title="Add role" @click="$emit('createRole', selectedOrg)" role="button" tabindex="0" @keydown.enter="$emit('createRole', selectedOrg)">➕</div>
|
||||||
|
|
||||||
<template v-for="pid in selectedOrg.permissions" :key="pid">
|
<template v-for="p in orgPermissions" :key="p.uuid">
|
||||||
<div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div>
|
<div class="perm-name" :title="p.scope">{{ p.display_name }}</div>
|
||||||
<div
|
<div
|
||||||
v-for="r in sortedRoles"
|
v-for="r in sortedRoles"
|
||||||
:key="r.uuid + '-' + pid"
|
:key="r.uuid + '-' + p.uuid"
|
||||||
class="matrix-cell"
|
class="matrix-cell"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="r.permissions.includes(pid)"
|
:checked="p.uuid in (r.permissions || {})"
|
||||||
@change="e => toggleRolePermission(r, pid, e.target.checked)"
|
@change="e => toggleRolePermission(r, p.uuid, e.target.checked)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="matrix-cell add-role-cell" />
|
<div class="matrix-cell add-role-cell" />
|
||||||
@@ -326,84 +368,87 @@ defineExpose({ focusFirstElement })
|
|||||||
v-for="(r, roleIndex) in sortedRoles"
|
v-for="(r, roleIndex) in sortedRoles"
|
||||||
:key="r.uuid"
|
:key="r.uuid"
|
||||||
class="role-column"
|
class="role-column"
|
||||||
@dragover="$emit('onRoleDragOver', $event)"
|
|
||||||
@drop="e => $emit('onRoleDrop', e, selectedOrg, r)"
|
|
||||||
>
|
>
|
||||||
<div class="role-header" @keydown="e => handleRoleHeaderKeydown(e, roleIndex)">
|
<div class="role-header" @keydown="e => handleRoleHeaderKeydown(e, roleIndex)">
|
||||||
<strong class="role-name" :title="r.uuid">
|
<strong class="role-name" :title="r.uuid">
|
||||||
<span>{{ r.display_name }}</span>
|
<span>{{ r.display_name }}</span>
|
||||||
<button @click="$emit('updateRole', r)" class="icon-btn" aria-label="Edit role" title="Edit role">✏️</button>
|
<button @click="$emit('updateRole', r)" class="icon-btn" aria-label="Edit role" title="Edit role">✏️</button>
|
||||||
|
<button v-if="roleUserCount(r.uuid) === 0" @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete role" title="Delete role">❌</button>
|
||||||
</strong>
|
</strong>
|
||||||
<div class="role-actions">
|
<div class="role-actions">
|
||||||
<button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user">➕</button>
|
<button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user">➕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="r.users.length > 0">
|
<div class="user-list-wrapper">
|
||||||
<ul class="user-list" @keydown="handleUserListKeydown">
|
<draggable
|
||||||
<li
|
:list="roleUsers(r.uuid)"
|
||||||
v-for="u in r.users.slice().sort((a, b) => {
|
group="users"
|
||||||
const nameA = a.display_name.toLowerCase()
|
item-key="uuid"
|
||||||
const nameB = b.display_name.toLowerCase()
|
tag="ul"
|
||||||
if (nameA !== nameB) {
|
class="user-list"
|
||||||
return nameA.localeCompare(nameB)
|
@change="evt => onUserChange(evt, r.uuid)"
|
||||||
}
|
@keydown="handleUserListKeydown"
|
||||||
return a.uuid.localeCompare(b.uuid)
|
>
|
||||||
})"
|
<template #item="{ element: u }">
|
||||||
:key="u.uuid"
|
<li
|
||||||
class="user-chip"
|
class="user-chip"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
draggable="true"
|
@click="$emit('openUser', u)"
|
||||||
@dragstart="e => $emit('onUserDragStart', e, u, selectedOrg.uuid)"
|
@keydown.enter="$emit('openUser', u)"
|
||||||
@click="$emit('openUser', u)"
|
:title="u.uuid"
|
||||||
@keydown.enter="$emit('openUser', u)"
|
>
|
||||||
:title="u.uuid"
|
<ProfilePicture
|
||||||
>
|
class="user-chip-picture"
|
||||||
<span class="name">{{ u.display_name }}</span>
|
:src="u.avatar_url"
|
||||||
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span>
|
:title="u.display_name"
|
||||||
</li>
|
width="3.25rem"
|
||||||
</ul>
|
height="100%"
|
||||||
</template>
|
radius="0"
|
||||||
<div v-else class="empty-role" @keydown="e => handleEmptyRoleKeydown(e, roleIndex)">
|
fallback-size="1.3rem"
|
||||||
<p class="empty-text muted">No members</p>
|
/>
|
||||||
<button @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete empty role" title="Delete role">❌</button>
|
<span class="user-chip-body">
|
||||||
|
<span class="name">{{ u.display_name }}</span>
|
||||||
|
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' }}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
<div v-if="roleUserCount(r.uuid) === 0" class="empty-role">
|
||||||
|
<p class="empty-text muted">No members</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="sortedRoles.length >= 2" class="roles-hint muted">Members can be drag&dropped to different roles.</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.card.surface { padding: var(--space-lg); }
|
.card.surface { padding: var(--space-lg); }
|
||||||
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); }
|
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); font-size: 1.65rem; }
|
||||||
.org-name { font-size: 1.5rem; font-weight: 600; color: var(--color-heading); }
|
.org-name { font-weight: 600; color: var(--color-heading); }
|
||||||
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
|
|
||||||
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
|
|
||||||
.matrix-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); }
|
|
||||||
.matrix-scroll { overflow-x: auto; }
|
|
||||||
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
|
|
||||||
.perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; }
|
|
||||||
.perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; }
|
|
||||||
.perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
|
|
||||||
.perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; }
|
|
||||||
.perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; }
|
.perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; }
|
||||||
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
|
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
|
||||||
.perm-matrix-grid .add-role-head { cursor: pointer; }
|
.perm-matrix-grid .add-role-head { cursor: pointer; }
|
||||||
.perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; }
|
.roles-grid { display: flex; flex-wrap: wrap; gap: 0; margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
|
||||||
.roles-grid { display: flex; gap: var(--space-lg); margin-top: var(--space-lg); }
|
.role-column { flex: 0 0 17em; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; }
|
||||||
.role-column { flex: 1; min-width: 200px; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--space-md); }
|
|
||||||
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
|
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
|
||||||
.role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); }
|
.role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); }
|
||||||
.role-actions { display: flex; gap: var(--space-xs); }
|
.role-actions { display: flex; gap: var(--space-xs); }
|
||||||
.plus-btn { background: var(--color-accent-soft); color: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: 0.25rem 0.45rem; font-size: 1.1rem; cursor: pointer; }
|
.plus-btn { background: none; color: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: 0.25rem 0.45rem; font-size: 1.1rem; cursor: pointer; }
|
||||||
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
|
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
|
||||||
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); }
|
.user-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; }
|
||||||
.user-chip { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
|
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; }
|
||||||
|
.user-chip { background: var(--color-accent-strong); color: var(--color-accent-contrast); border: none; border-radius: var(--radius-md); padding: 0; display: grid; grid-template-columns: 3.25rem minmax(0, 1fr); align-items: stretch; gap: 0; cursor: grab; overflow: hidden; min-height: 3.25rem; }
|
||||||
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
|
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
|
||||||
.user-chip .meta { font-size: 0.7rem; color: var(--color-text-muted); }
|
.user-chip-picture { align-self: stretch; }
|
||||||
.empty-role { border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-sm); display: flex; flex-direction: column; gap: var(--space-xs); align-items: flex-start; }
|
.user-chip-body { display: flex; min-width: 0; flex-direction: column; justify-content: center; gap: 0.1rem; padding: 0.45rem 0.6rem; }
|
||||||
|
.user-chip .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.user-chip .meta { font-size: 0.7rem; opacity: 0.85; }
|
||||||
|
.user-chip.sortable-ghost { opacity: 0.5; }
|
||||||
|
.user-chip.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
|
||||||
|
.empty-role { position: absolute; inset: 0; border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||||||
|
.user-list:has(.sortable-ghost) + .empty-role { display: none; }
|
||||||
.empty-text { margin: 0; }
|
.empty-text { margin: 0; }
|
||||||
.delete-icon { color: var(--color-danger); }
|
|
||||||
.delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); }
|
|
||||||
.muted { color: var(--color-text-muted); }
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.roles-grid { flex-direction: column; }
|
.roles-grid { flex-direction: column; }
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
|
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
|
||||||
|
import { formatDate } from '@/utils/helpers'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
info: Object,
|
info: Object,
|
||||||
orgs: Array,
|
orgs: Array,
|
||||||
permissions: Array,
|
permissions: Array,
|
||||||
|
oidcClients: Array,
|
||||||
permissionSummary: Object,
|
permissionSummary: Object,
|
||||||
navigationDisabled: { type: Boolean, default: false }
|
navigationDisabled: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'navigateOut'])
|
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut'])
|
||||||
|
|
||||||
// Template refs for navigation
|
// Template refs for navigation
|
||||||
const orgSection = ref(null)
|
|
||||||
const orgActionsRef = ref(null)
|
const orgActionsRef = ref(null)
|
||||||
const orgTableRef = ref(null)
|
const orgTableRef = ref(null)
|
||||||
const permMatrixRef = ref(null)
|
const permMatrixRef = ref(null)
|
||||||
@@ -21,23 +22,56 @@ const permActionsRef = ref(null)
|
|||||||
const permTableRef = ref(null)
|
const permTableRef = ref(null)
|
||||||
|
|
||||||
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
|
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
|
||||||
const nameCompare = a.display_name.localeCompare(b.display_name)
|
const nameCompare = a.org.display_name.localeCompare(b.org.display_name)
|
||||||
return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid)
|
return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid)
|
||||||
}))
|
}))
|
||||||
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.id.localeCompare(b.id)))
|
|
||||||
|
|
||||||
function permissionDisplayName(id) {
|
// Map OIDC client UUIDs to display names for permission domain column
|
||||||
return props.permissions.find(p => p.id === id)?.display_name || id
|
const oidcClientNames = computed(() => {
|
||||||
|
const map = {}
|
||||||
|
for (const c of props.oidcClients || []) map[c.uuid] = c.name
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
function domainDisplay(domain) {
|
||||||
|
if (!domain) return '—'
|
||||||
|
return oidcClientNames.value[domain] || domain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map OIDC client UUIDs to their group permissions (sorted by scope)
|
||||||
|
const clientGroups = computed(() => {
|
||||||
|
const map = {}
|
||||||
|
for (const p of props.permissions || []) {
|
||||||
|
if (p.domain) {
|
||||||
|
if (!map[p.domain]) map[p.domain] = []
|
||||||
|
map[p.domain].push(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort each group array by scope
|
||||||
|
for (const key in map) {
|
||||||
|
map[key].sort((a, b) => a.scope.localeCompare(b.scope))
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope)))
|
||||||
|
|
||||||
|
// Derive admin status from permissions (info contains ctx from validate response)
|
||||||
|
const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin'))
|
||||||
|
const isOrgAdmin = computed(() => props.info?.ctx.permissions.includes('auth:org:admin'))
|
||||||
|
|
||||||
function getRoleNames(org) {
|
function getRoleNames(org) {
|
||||||
return org.roles
|
// org.roles is dict[UUID, Role]
|
||||||
|
return Object.values(org.roles)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.display_name.localeCompare(b.display_name))
|
.sort((a, b) => a.display_name.localeCompare(b.display_name))
|
||||||
.map(r => r.display_name)
|
.map(r => r.display_name)
|
||||||
.join(', ')
|
.join(', ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function orgUserCount(org) {
|
||||||
|
return Object.keys(org.users).length
|
||||||
|
}
|
||||||
|
|
||||||
// Table navigation for both org and permissions tables
|
// Table navigation for both org and permissions tables
|
||||||
function handleTableKeydown(event, tableType) {
|
function handleTableKeydown(event, tableType) {
|
||||||
if (props.navigationDisabled) return
|
if (props.navigationDisabled) return
|
||||||
@@ -89,7 +123,7 @@ function handleTableKeydown(event, tableType) {
|
|||||||
} else if (direction === 'down' && currentIndex === rows.length - 1) {
|
} else if (direction === 'down' && currentIndex === rows.length - 1) {
|
||||||
// At bottom of org table, navigate to permissions section
|
// At bottom of org table, navigate to permissions section
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (tableType === 'org' && props.info.is_global_admin) {
|
if (tableType === 'org' && isMasterAdmin.value) {
|
||||||
// Navigate to permissions matrix or actions
|
// Navigate to permissions matrix or actions
|
||||||
if (permMatrixRef.value) {
|
if (permMatrixRef.value) {
|
||||||
const firstCheckbox = permMatrixRef.value.querySelector('input[type="checkbox"]')
|
const firstCheckbox = permMatrixRef.value.querySelector('input[type="checkbox"]')
|
||||||
@@ -232,7 +266,7 @@ function handlePermActionsKeydown(event) {
|
|||||||
|
|
||||||
// Focus helper for external navigation
|
// Focus helper for external navigation
|
||||||
function focusFirstElement() {
|
function focusFirstElement() {
|
||||||
if (props.info.is_global_admin) {
|
if (isMasterAdmin.value) {
|
||||||
focusPreferred(orgActionsRef.value, { itemSelector: 'button' })
|
focusPreferred(orgActionsRef.value, { itemSelector: 'button' })
|
||||||
} else {
|
} else {
|
||||||
const firstFocusable = orgTableRef.value?.querySelector('tbody tr a, tbody tr button:not([disabled])')
|
const firstFocusable = orgTableRef.value?.querySelector('tbody tr a, tbody tr button:not([disabled])')
|
||||||
@@ -245,9 +279,9 @@ defineExpose({ focusFirstElement })
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="permissions-section" ref="orgSection">
|
<div class="permissions-section" ref="orgSection">
|
||||||
<h2>{{ info.is_global_admin ? 'Organizations' : 'Your Organizations' }}</h2>
|
<h2>{{ isMasterAdmin ? 'Organizations' : 'Your Organizations' }}</h2>
|
||||||
<div class="actions" ref="orgActionsRef" @keydown="handleOrgActionsKeydown">
|
<div class="actions" ref="orgActionsRef" @keydown="handleOrgActionsKeydown">
|
||||||
<button v-if="info.is_global_admin" @click="$emit('createOrg')">+ Create Org</button>
|
<button v-if="isMasterAdmin" @click="$emit('createOrg')">+ Create Org</button>
|
||||||
</div>
|
</div>
|
||||||
<table class="org-table" ref="orgTableRef" @keydown="e => handleTableKeydown(e, 'org')">
|
<table class="org-table" ref="orgTableRef" @keydown="e => handleTableKeydown(e, 'org')">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -255,18 +289,18 @@ defineExpose({ focusFirstElement })
|
|||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Roles</th>
|
<th>Roles</th>
|
||||||
<th>Members</th>
|
<th>Members</th>
|
||||||
<th v-if="info.is_global_admin">Actions</th>
|
<th v-if="isMasterAdmin">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="o in sortedOrgs" :key="o.uuid">
|
<tr v-for="o in sortedOrgs" :key="o.uuid">
|
||||||
<td>
|
<td>
|
||||||
<a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.display_name }}</a>
|
<a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.org.display_name }}</a>
|
||||||
<button v-if="info.is_global_admin || info.is_org_admin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
<button v-if="isMasterAdmin || isOrgAdmin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
||||||
</td>
|
</td>
|
||||||
<td class="role-names">{{ getRoleNames(o) }}</td>
|
<td class="role-names">{{ getRoleNames(o) }}</td>
|
||||||
<td class="center">{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td>
|
<td class="center">{{ orgUserCount(o) }}</td>
|
||||||
<td v-if="info.is_global_admin" class="center">
|
<td v-if="isMasterAdmin" class="center">
|
||||||
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization">❌</button>
|
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization">❌</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -274,7 +308,7 @@ defineExpose({ focusFirstElement })
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="info.is_global_admin" class="permissions-section">
|
<div v-if="isMasterAdmin" class="permissions-section">
|
||||||
<h2>Permissions</h2>
|
<h2>Permissions</h2>
|
||||||
<div class="matrix-wrapper" ref="permMatrixRef" @keydown="handleMatrixKeydown">
|
<div class="matrix-wrapper" ref="permMatrixRef" @keydown="handleMatrixKeydown">
|
||||||
<div class="matrix-scroll">
|
<div class="matrix-scroll">
|
||||||
@@ -287,24 +321,24 @@ defineExpose({ focusFirstElement })
|
|||||||
v-for="o in sortedOrgs"
|
v-for="o in sortedOrgs"
|
||||||
:key="'head-' + o.uuid"
|
:key="'head-' + o.uuid"
|
||||||
class="grid-head org-head"
|
class="grid-head org-head"
|
||||||
:title="o.display_name"
|
:title="o.org.display_name"
|
||||||
>
|
>
|
||||||
<span>{{ o.display_name }}</span>
|
<span>{{ o.org.display_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-for="p in sortedPermissions" :key="p.id">
|
<template v-for="p in sortedPermissions" :key="p.uuid">
|
||||||
<div class="perm-name" :title="p.id">
|
<div class="perm-name" :title="p.scope">
|
||||||
<span class="display-text">{{ p.display_name }}</span>
|
<span class="display-text">{{ p.display_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="o in sortedOrgs"
|
v-for="o in sortedOrgs"
|
||||||
:key="o.uuid + '-' + p.id"
|
:key="o.uuid + '-' + p.uuid"
|
||||||
class="matrix-cell"
|
class="matrix-cell"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="o.permissions.includes(p.id)"
|
:checked="p.uuid in o.permissions"
|
||||||
@change="e => $emit('toggleOrgPermission', o, p.id, e.target.checked)"
|
@change="e => $emit('toggleOrgPermission', o, p.uuid, e.target.checked)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -313,28 +347,30 @@ defineExpose({ focusFirstElement })
|
|||||||
<p class="matrix-hint muted">Toggle which permissions each organization can grant to its members.</p>
|
<p class="matrix-hint muted">Toggle which permissions each organization can grant to its members.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions" ref="permActionsRef" @keydown="handlePermActionsKeydown">
|
<div class="actions" ref="permActionsRef" @keydown="handlePermActionsKeydown">
|
||||||
<button v-if="info.is_global_admin" @click="$emit('openDialog', 'perm-create', { display_name: '', id: '' })">+ Create Permission</button>
|
<button v-if="isMasterAdmin" @click="$emit('openDialog', 'perm-create', { display_name: '', scope: '', domain: '' })">+ Create Permission</button>
|
||||||
</div>
|
</div>
|
||||||
<table class="org-table" ref="permTableRef" @keydown="e => handleTableKeydown(e, 'perm')">
|
<table class="org-table" ref="permTableRef" @keydown="e => handleTableKeydown(e, 'perm')">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Permission</th>
|
<th scope="col">Permission</th>
|
||||||
|
<th scope="col">Domain</th>
|
||||||
<th scope="col" class="center">Members</th>
|
<th scope="col" class="center">Members</th>
|
||||||
<th scope="col" class="center">Actions</th>
|
<th scope="col" class="center">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="p in sortedPermissions" :key="p.id">
|
<tr v-for="p in sortedPermissions" :key="p.uuid">
|
||||||
<td class="perm-name-cell">
|
<td class="perm-name-cell">
|
||||||
<div class="perm-title">
|
<div class="perm-title">
|
||||||
<span class="display-text">{{ p.display_name }}</span>
|
<span class="display-text">{{ p.display_name }}</span>
|
||||||
<button @click="$emit('renamePermissionDisplay', p)" class="icon-btn edit-display-btn" aria-label="Edit display name" title="Edit display name">✏️</button>
|
<button @click="$emit('renamePermissionDisplay', p)" class="icon-btn edit-display-btn" aria-label="Edit permission" title="Edit permission">✏️</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="perm-id-info">
|
<div class="perm-id-info">
|
||||||
<span class="id-text">{{ p.id }}</span>
|
<span class="id-text">{{ p.scope }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="perm-members center">{{ permissionSummary[p.id]?.userCount || 0 }}</td>
|
<td class="perm-domain">{{ domainDisplay(p.domain) }}</td>
|
||||||
|
<td class="perm-members center">{{ permissionSummary[p.uuid]?.userCount || 0 }}</td>
|
||||||
<td class="perm-actions center">
|
<td class="perm-actions center">
|
||||||
<button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission">❌</button>
|
<button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission">❌</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -342,38 +378,88 @@ defineExpose({ focusFirstElement })
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isMasterAdmin" class="oidc-clients-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>OAuth2 / OpenID Connect</h2>
|
||||||
|
<p class="section-description">
|
||||||
|
Allow external websites and applications to securely authenticate users through this system.
|
||||||
|
The clients are remote sites or applications that we allow to use Paskia for Single Sign-On.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div ref="oidcActionsRef">
|
||||||
|
<button @click="$emit('createOidcClient')">+ Add Site</button>
|
||||||
|
</div>
|
||||||
|
<table class="org-table" ref="oidcTableRef">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Client</th>
|
||||||
|
<th scope="col">Groups</th>
|
||||||
|
<th scope="col" class="center">Sessions</th>
|
||||||
|
<th scope="col" class="center">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="!oidcClients || oidcClients.length === 0">
|
||||||
|
<td colspan="4" class="center muted">No OIDC clients configured</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="client in oidcClients" :key="client.uuid">
|
||||||
|
<td class="perm-name-cell">
|
||||||
|
<div class="perm-title">
|
||||||
|
<a :href="'#oidc:' + client.uuid" @click.prevent="$emit('openOidcClient', client)">{{ client.name }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="perm-id-info">
|
||||||
|
<span class="id-text">{{ client.uuid }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="client-groups">
|
||||||
|
<span v-if="clientGroups[client.uuid]?.length">{{ clientGroups[client.uuid].map(g => g.scope).join(' ') }}</span>
|
||||||
|
<span v-else class="muted">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="center">{{ client.active_sessions || 0 }}</td>
|
||||||
|
<td class="center">
|
||||||
|
<button @click="$emit('deleteOidcClient', client)" class="icon-btn delete-icon" aria-label="Delete OIDC client" title="Delete OIDC client">❌</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isMasterAdmin" class="server-options-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Server</h2>
|
||||||
|
<p class="section-description">
|
||||||
|
Configure core server settings such as the display name, authentication host, and allowed origins.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button @click="$emit('openServerConfig')">⚙ Server Options</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.permissions-section { margin-bottom: var(--space-xl); }
|
.permissions-section { margin-bottom: var(--space-xl); }
|
||||||
.permissions-section h2 { margin-bottom: var(--space-md); }
|
.permissions-section h2 { margin-bottom: var(--space-md); }
|
||||||
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
|
|
||||||
.actions button { width: auto; }
|
|
||||||
.org-table a { text-decoration: none; color: var(--color-link); }
|
.org-table a { text-decoration: none; color: var(--color-link); }
|
||||||
.org-table a:hover { text-decoration: underline; }
|
.org-table a:hover { text-decoration: underline; }
|
||||||
.org-table .center { width: 6rem; min-width: 6rem; }
|
.org-table .center { width: 6rem; min-width: 6rem; }
|
||||||
.org-table .role-names { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.org-table .role-names { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.perm-name-cell { display: flex; flex-direction: column; gap: 0.3rem; }
|
.perm-name-cell { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
.perm-title { font-weight: 600; color: var(--color-heading); }
|
.perm-title { font-weight: 600; color: var(--color-heading); }
|
||||||
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); }
|
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
|
.perm-domain { color: var(--color-text-muted); font-size: 0.9rem; }
|
||||||
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
|
|
||||||
.delete-icon { color: var(--color-danger); }
|
|
||||||
.delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); }
|
|
||||||
.matrix-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); }
|
|
||||||
.matrix-scroll { overflow-x: auto; }
|
|
||||||
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
|
|
||||||
.perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; }
|
|
||||||
.perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; }
|
|
||||||
.perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
|
|
||||||
.perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; }
|
|
||||||
.perm-matrix-grid .org-head { display: flex; align-items: flex-end; justify-content: center; }
|
.perm-matrix-grid .org-head { display: flex; align-items: flex-end; justify-content: center; }
|
||||||
.perm-matrix-grid .org-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
|
.perm-matrix-grid .org-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
|
||||||
.perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; }
|
|
||||||
.display-text { margin-right: var(--space-xs); }
|
.display-text { margin-right: var(--space-xs); }
|
||||||
.edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; }
|
.edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; }
|
||||||
.edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); }
|
.edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); }
|
||||||
.perm-actions { text-align: center; }
|
.perm-actions { text-align: center; }
|
||||||
.center { text-align: center; }
|
|
||||||
.muted { color: var(--color-text-muted); }
|
/* OIDC Clients Section */
|
||||||
|
.oidc-clients-section { margin-bottom: var(--space-xl); margin-top: var(--space-2xl); }
|
||||||
|
.oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
|
||||||
|
.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); }
|
||||||
|
|
||||||
|
/* Server Options Section */
|
||||||
|
.server-options-section { margin-top: var(--space-2xl); }
|
||||||
|
.server-options-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||||
import CredentialList from '@/components/CredentialList.vue'
|
import CredentialList from '@/components/CredentialList.vue'
|
||||||
|
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
|
||||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||||
import SessionList from '@/components/SessionList.vue'
|
import SessionList from '@/components/SessionList.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { apiJson } from '@/utils/api'
|
import { apiJson } from 'paskia'
|
||||||
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
|
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -17,12 +18,19 @@ const props = defineProps({
|
|||||||
navigationDisabled: { type: Boolean, default: false }
|
navigationDisabled: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut'])
|
const emit = defineEmits(['generateUserRegistrationLink', 'openOrg', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut', 'deleteUser'])
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const terminatingSessions = ref({})
|
const terminatingSessions = ref({})
|
||||||
const hoveredCredentialUuid = ref(null)
|
const hoveredCredentialUuid = ref(null)
|
||||||
const hoveredSession = ref(null)
|
const hoveredSession = ref(null)
|
||||||
|
const showPictureDialog = ref(false)
|
||||||
|
const avatarRenderVersion = ref(0)
|
||||||
|
|
||||||
|
// Convert credentials dict to array with uuid attached as 'credential'
|
||||||
|
const credentials = computed(() =>
|
||||||
|
Object.entries(props.userDetail?.credentials || {}).map(([uuid, c]) => ({ ...c, credential: uuid }))
|
||||||
|
)
|
||||||
|
|
||||||
// Template refs for navigation
|
// Template refs for navigation
|
||||||
const userInfoRef = ref(null)
|
const userInfoRef = ref(null)
|
||||||
@@ -43,32 +51,48 @@ function handleEditName() {
|
|||||||
emit('editUserName', props.selectedUser)
|
emit('editUserName', props.selectedUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openPictureDialog() {
|
||||||
|
if (!props.userDetail || props.userDetail.error) return
|
||||||
|
showPictureDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePictureDialog() {
|
||||||
|
showPictureDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePictureUpdated() {
|
||||||
|
avatarRenderVersion.value += 1
|
||||||
|
emit('refreshUserDetail')
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete(credential) {
|
async function handleDelete(credential) {
|
||||||
try {
|
try {
|
||||||
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/credentials/${credential.credential_uuid}`, { method: 'DELETE' })
|
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
|
||||||
if (data.status === 'ok') {
|
if (data.status === 'ok') {
|
||||||
emit('onUserNameSaved') // Reuse to refresh user detail
|
emit('refreshUserDetail')
|
||||||
|
authStore.showMessage('Passkey removed', 'success', 2500)
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to delete credential', data)
|
authStore.showMessage(data.detail || 'Failed to remove passkey', 'error')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
authStore.showMessage(err.message || 'Failed to remove passkey', 'error')
|
||||||
console.error('Delete credential error', err)
|
console.error('Delete credential error', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTerminateSession(session) {
|
async function handleTerminateSession(session) {
|
||||||
const sessionId = session?.id
|
const sessionKey = session?.key
|
||||||
if (!sessionId) return
|
if (!sessionKey) return
|
||||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
|
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
|
||||||
try {
|
try {
|
||||||
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' })
|
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/sessions/${sessionKey}`, { method: 'DELETE' })
|
||||||
if (data.status === 'ok') {
|
if (data.status === 'ok') {
|
||||||
if (data.current_session_terminated) {
|
if (data.current_session_terminated) {
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
location.reload()
|
location.reload()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
emit('refreshUserDetail') // Refresh without showing rename message
|
emit('refreshUserDetail')
|
||||||
authStore.showMessage('Session terminated', 'success', 2500)
|
authStore.showMessage('Session terminated', 'success', 2500)
|
||||||
} else {
|
} else {
|
||||||
authStore.showMessage(data.detail || 'Failed to terminate session', 'error')
|
authStore.showMessage(data.detail || 'Failed to terminate session', 'error')
|
||||||
@@ -78,11 +102,15 @@ async function handleTerminateSession(session) {
|
|||||||
authStore.showMessage(err.message || 'Failed to terminate session', 'error')
|
authStore.showMessage(err.message || 'Failed to terminate session', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
const next = { ...terminatingSessions.value }
|
const next = { ...terminatingSessions.value }
|
||||||
delete next[sessionId]
|
delete next[sessionKey]
|
||||||
terminatingSessions.value = next
|
terminatingSessions.value = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDeleteUser() {
|
||||||
|
emit('deleteUser')
|
||||||
|
}
|
||||||
|
|
||||||
// Handle user info section keynav
|
// Handle user info section keynav
|
||||||
function handleUserInfoKeydown(event) {
|
function handleUserInfoKeydown(event) {
|
||||||
if (hasActiveModal.value || props.navigationDisabled) return
|
if (hasActiveModal.value || props.navigationDisabled) return
|
||||||
@@ -93,7 +121,7 @@ function handleUserInfoKeydown(event) {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (direction === 'left' || direction === 'right') {
|
if (direction === 'left' || direction === 'right') {
|
||||||
navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.mini-btn' })
|
navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.user-picture-btn, .mini-btn' })
|
||||||
} else if (direction === 'up') {
|
} else if (direction === 'up') {
|
||||||
emit('navigateOut', 'up')
|
emit('navigateOut', 'up')
|
||||||
} else if (direction === 'down') {
|
} else if (direction === 'down') {
|
||||||
@@ -115,7 +143,7 @@ function handleRegActionsKeydown(event) {
|
|||||||
navigateButtonRow(regActionsRef.value, event.target, direction, { itemSelector: 'button' })
|
navigateButtonRow(regActionsRef.value, event.target, direction, { itemSelector: 'button' })
|
||||||
} else if (direction === 'up') {
|
} else if (direction === 'up') {
|
||||||
// Move to user info edit button
|
// Move to user info edit button
|
||||||
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
|
focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
|
||||||
} else if (direction === 'down') {
|
} else if (direction === 'down') {
|
||||||
// Move to credential list
|
// Move to credential list
|
||||||
credentialListRef.value?.$el?.focus()
|
credentialListRef.value?.$el?.focus()
|
||||||
@@ -165,10 +193,22 @@ function handleBackButtonKeydown(event) {
|
|||||||
|
|
||||||
// Focus helper for external navigation
|
// Focus helper for external navigation
|
||||||
function focusFirstElement() {
|
function focusFirstElement() {
|
||||||
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
|
focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ focusFirstElement })
|
defineExpose({ focusFirstElement })
|
||||||
|
|
||||||
|
const currentPictureEndpoint = computed(() => {
|
||||||
|
if (!props.selectedUser?.uuid) return null
|
||||||
|
return `/auth/api/user/${props.selectedUser.uuid}/profile.webp`
|
||||||
|
})
|
||||||
|
|
||||||
|
const adminPictureTitle = computed(() => {
|
||||||
|
const username = props.userDetail?.user?.preferred_username || props.selectedUser?.preferred_username
|
||||||
|
const displayName = props.userDetail?.user?.display_name || props.selectedUser?.display_name
|
||||||
|
const label = username || displayName || 'User'
|
||||||
|
return `Profile Picture for ${label}`
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -176,31 +216,40 @@ defineExpose({ focusFirstElement })
|
|||||||
<div ref="userInfoRef" @keydown="handleUserInfoKeydown">
|
<div ref="userInfoRef" @keydown="handleUserInfoKeydown">
|
||||||
<UserBasicInfo
|
<UserBasicInfo
|
||||||
v-if="userDetail && !userDetail.error"
|
v-if="userDetail && !userDetail.error"
|
||||||
:name="userDetail.display_name || selectedUser.display_name"
|
:name="userDetail.user.display_name || selectedUser.display_name"
|
||||||
:visits="userDetail.visits"
|
:avatar-url="userDetail.user.avatar_url"
|
||||||
:created-at="userDetail.created_at"
|
:avatar-render-version="avatarRenderVersion"
|
||||||
:last-seen="userDetail.last_seen"
|
avatar-clickable
|
||||||
|
:visits="userDetail.user.visits"
|
||||||
|
:created-at="userDetail.user.created_at"
|
||||||
|
:last-seen="userDetail.user.last_seen"
|
||||||
|
:email="userDetail.user.email"
|
||||||
|
:telephone="userDetail.user.telephone"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:org-display-name="userDetail.org.display_name"
|
:org-display-name="userDetail.org.display_name"
|
||||||
:role-name="userDetail.role"
|
:role-name="userDetail.role.display_name"
|
||||||
:update-endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
|
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
|
||||||
@saved="$emit('onUserNameSaved')"
|
@avatar-click="openPictureDialog"
|
||||||
@edit-name="handleEditName"
|
@edit="handleEditName"
|
||||||
/>
|
>
|
||||||
|
<div class="admin-actions">
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
@click="$emit('generateUserRegistrationLink', selectedUser)"
|
||||||
|
:disabled="loading"
|
||||||
|
title="Generate a one-time link for this user"
|
||||||
|
>{{ userDetail?.credentials && Object.keys(userDetail.credentials).length > 0 ? 'Recovery Link' : 'Registration Link' }}</button>
|
||||||
|
<button
|
||||||
|
class="btn-danger"
|
||||||
|
@click="handleDeleteUser"
|
||||||
|
:disabled="loading"
|
||||||
|
title="Delete this user"
|
||||||
|
>Delete User</button>
|
||||||
|
</div>
|
||||||
|
</UserBasicInfo>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
|
<div v-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
|
||||||
<template v-if="userDetail && !userDetail.error">
|
<template v-if="userDetail && !userDetail.error">
|
||||||
<div class="registration-actions" ref="regActionsRef" @keydown="handleRegActionsKeydown">
|
|
||||||
<button
|
|
||||||
class="btn-secondary reg-token-btn"
|
|
||||||
@click="$emit('generateUserRegistrationLink', selectedUser)"
|
|
||||||
:disabled="loading"
|
|
||||||
>Generate Registration Token</button>
|
|
||||||
<p class="matrix-hint muted">
|
|
||||||
Generate a one-time registration link so this user can register or add another passkey.
|
|
||||||
Copy the link from the dialog and send it to the user, or have the user scan the QR code on their device.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<section class="section-block" data-section="registered-passkeys">
|
<section class="section-block" data-section="registered-passkeys">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Registered Passkeys</h2>
|
<h2>Registered Passkeys</h2>
|
||||||
@@ -208,11 +257,11 @@ defineExpose({ focusFirstElement })
|
|||||||
<div class="section-body">
|
<div class="section-body">
|
||||||
<CredentialList
|
<CredentialList
|
||||||
ref="credentialListRef"
|
ref="credentialListRef"
|
||||||
:credentials="userDetail.credentials"
|
:credentials="credentials"
|
||||||
:aaguid-info="userDetail.aaguid_info"
|
:aaguid-info="userDetail.aaguid_info"
|
||||||
:allow-delete="true"
|
:allow-delete="true"
|
||||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||||
:hovered-session-credential-uuid="hoveredSession?.credential_uuid"
|
:hovered-session-credential-uuid="hoveredSession?.credential"
|
||||||
:navigation-disabled="hasActiveModal"
|
:navigation-disabled="hasActiveModal"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
@credential-hover="hoveredCredentialUuid = $event"
|
@credential-hover="hoveredCredentialUuid = $event"
|
||||||
@@ -222,7 +271,7 @@ defineExpose({ focusFirstElement })
|
|||||||
</section>
|
</section>
|
||||||
<SessionList
|
<SessionList
|
||||||
ref="sessionListRef"
|
ref="sessionListRef"
|
||||||
:sessions="userDetail.sessions || []"
|
:sessions="userDetail.sessions || {}"
|
||||||
:terminating-sessions="terminatingSessions"
|
:terminating-sessions="terminatingSessions"
|
||||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||||
:navigation-disabled="hasActiveModal"
|
:navigation-disabled="hasActiveModal"
|
||||||
@@ -238,24 +287,25 @@ defineExpose({ focusFirstElement })
|
|||||||
</div>
|
</div>
|
||||||
<RegistrationLinkModal
|
<RegistrationLinkModal
|
||||||
v-if="showRegModal"
|
v-if="showRegModal"
|
||||||
:endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
|
:endpoint="`/auth/api/admin/users/${selectedUser.uuid}/create-link`"
|
||||||
:user-name="userDetail?.display_name || selectedUser.display_name"
|
:user-name="userDetail?.user?.display_name || selectedUser.display_name"
|
||||||
@close="$emit('closeRegModal')"
|
@close="$emit('closeRegModal')"
|
||||||
@copied="onLinkCopied"
|
@copied="onLinkCopied"
|
||||||
/>
|
/>
|
||||||
|
<ProfilePictureEditorModal
|
||||||
|
v-if="showPictureDialog && currentPictureEndpoint"
|
||||||
|
:endpoint="currentPictureEndpoint"
|
||||||
|
:picture-url="userDetail?.user?.avatar_url"
|
||||||
|
:render-version="avatarRenderVersion"
|
||||||
|
:title="adminPictureTitle"
|
||||||
|
@close="closePictureDialog"
|
||||||
|
@updated="handlePictureUpdated"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
|
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
|
||||||
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
|
.admin-actions { display: flex; gap: 0.5rem; }
|
||||||
.ancillary-actions { margin-top: -0.5rem; }
|
.ancillary-actions { margin-top: -0.5rem; }
|
||||||
.reg-token-btn { align-self: flex-start; }
|
|
||||||
.registration-actions { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
||||||
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
|
|
||||||
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
|
|
||||||
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
|
|
||||||
.error { color: var(--color-danger-text); }
|
|
||||||
.small { font-size: 0.9rem; }
|
|
||||||
.muted { color: var(--color-text-muted); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="340" height="340">
|
||||||
|
<path fill="#DDD" d="m169,.5a169,169 0 1,0 2,0zm0,86a76,76 0 1
|
||||||
|
1-2,0zM57,287q27-35 67-35h92q40,0 67,35a164,164 0 0,1-226,0"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 220 B |
+288
-123
@@ -1,68 +1,90 @@
|
|||||||
|
|
||||||
|
@property --hue {
|
||||||
|
syntax: '<angle>';
|
||||||
|
inherits: true;
|
||||||
|
initial-value: 0.72turn;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
|
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
|
||||||
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
--color-canvas: #ffffff;
|
--color-canvas: white;
|
||||||
--color-surface: #eff6ff;
|
--color-surface: #def;
|
||||||
--color-surface-subtle: #dbeafe;
|
--color-surface-subtle: #bcf;
|
||||||
--color-border: #2563eb;
|
--color-surface-hover: oklab(0.97 -0.01 -0.02);
|
||||||
--color-border-strong: #1e40af;
|
--color-dialog: oklab(0.96 -0.01 -0.03);
|
||||||
--color-heading: #1e3a8a;
|
--color-border: oklab(0.82 -0.02 -0.06);
|
||||||
--color-text: #1e293b;
|
--color-border-strong: oklab(0.55 -0.05 -0.14);
|
||||||
--color-text-muted: #475569;
|
--color-heading: oklab(0.22 -0.03 -0.09);
|
||||||
--color-link: #1d4ed8;
|
--color-text: oklab(0.2 -0.02 -0.05);
|
||||||
--color-link-hover: #1e40af;
|
--color-text-muted: oklab(0.42 -0.02 -0.06);
|
||||||
--color-accent: #2563eb;
|
--color-link: oklab(0.5 -0.06 -0.17);
|
||||||
--color-accent-strong: #1e40af;
|
--color-link-hover: oklab(0.45 -0.06 -0.19);
|
||||||
--color-accent-contrast: #ffffff;
|
--color-accent: oklab(0.55 -0.06 -0.19);
|
||||||
--color-success-text: #166534;
|
--color-accent-strong: #46f;
|
||||||
--color-success-bg: #dcfce7;
|
--color-accent-contrast: white;
|
||||||
--color-error-text: #b91c1c;
|
--color-secondary: oklab(0.55 -0.02 -0.05);
|
||||||
--color-error-bg: #fee2e2;
|
--color-secondary-strong: oklab(0.45 -0.02 -0.05);
|
||||||
--color-info-text: #1e40af;
|
--color-success-text: oklab(0.4 -0.12 0.09);
|
||||||
--color-info-bg: #dbeafe;
|
--color-success-bg: oklab(0.95 -0.02 0.02);
|
||||||
--color-danger: #dc2626;
|
--color-error-text: oklab(0.45 0.18 0.09);
|
||||||
--shadow-soft: 0 10px 30px rgba(30, 64, 175, 0.15);
|
--color-error-bg: oklab(0.95 0.03 0.01);
|
||||||
--radius-none: 0;
|
--color-info-text: oklab(0.45 -0.05 -0.14);
|
||||||
|
--color-info-bg: oklab(0.95 -0.01 -0.02);
|
||||||
|
--color-danger: oklab(0.55 0.20 0.09);
|
||||||
|
--color-primary: oklab(0.55 -0.06 -0.19);
|
||||||
|
--color-error: oklab(0.45 0.18 0.09);
|
||||||
|
--color-success: oklab(0.4 -0.12 0.09);
|
||||||
|
--color-bg: oklab(0.94 -0.01 -0.03);
|
||||||
|
--color-accent-soft: oklab(0.92 -0.03 -0.08);
|
||||||
|
--shadow-soft: 0 0 .2rem black;
|
||||||
|
--shadow-xl: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||||
--radius-sm: 4px;
|
--radius-sm: 4px;
|
||||||
--radius-md: 6px;
|
--radius-md: 6px;
|
||||||
--radius-lg: 10px;
|
--radius-lg: 10px;
|
||||||
--space-xxs: 0.25rem;
|
|
||||||
--space-xs: 0.5rem;
|
--space-xs: 0.5rem;
|
||||||
--space-sm: 0.75rem;
|
--space-sm: 0.75rem;
|
||||||
--space-md: 1rem;
|
--space-md: 1rem;
|
||||||
--space-lg: 1.5rem;
|
--space-lg: 1.5rem;
|
||||||
--space-xl: 2.25rem;
|
--space-xl: 2.25rem;
|
||||||
--space-xxl: 3.5rem;
|
--space-3xl: 5rem;
|
||||||
--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);
|
--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);
|
||||||
--transition-base: 160ms ease;
|
--transition-base: 160ms ease;
|
||||||
--focus-ring: 0 0 0 2px var(--color-accent);
|
--focus-ring: 0 0 0 2px var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
:root.dark {
|
||||||
:root {
|
--color-canvas: oklab(0.17 -0.02 -0.05);
|
||||||
--color-canvas: #0f172a;
|
--color-surface: oklab(0.22 -0.02 -0.05);
|
||||||
--color-surface: #141b2f;
|
--color-surface-subtle: oklab(0.25 -0.02 -0.05);
|
||||||
--color-surface-subtle: #1b243b;
|
--color-surface-hover: oklab(0.28 -0.02 -0.05);
|
||||||
--color-border: #25304a;
|
--color-dialog: oklab(0.22 -0.02 -0.05);
|
||||||
--color-border-strong: #3d4d6b;
|
--color-border: oklab(0.3 -0.02 -0.05);
|
||||||
--color-heading: #fff;
|
--color-border-strong: oklab(0.4 -0.02 -0.05);
|
||||||
--color-text: #e2e8f0;
|
--color-heading: white;
|
||||||
--color-text-muted: #94a3b8;
|
--color-text: oklab(0.9 0.00 -0.01);
|
||||||
--color-link: #60a5fa;
|
--color-text-muted: oklab(0.7 -0.01 -0.02);
|
||||||
--color-link-hover: #93c5fd;
|
--color-link: oklab(0.7 -0.05 -0.14);
|
||||||
--color-accent: #60a5fa;
|
--color-link-hover: oklab(0.8 -0.04 -0.11);
|
||||||
--color-accent-strong: #3b82f6;
|
--color-accent: oklab(0.7 -0.05 -0.14);
|
||||||
--color-accent-contrast: #0b1120;
|
--color-accent-strong: oklab(0.6 -0.06 -0.17);
|
||||||
--color-success-text: #34d399;
|
--color-accent-contrast: oklab(0.12 -0.01 -0.03);
|
||||||
--color-success-bg: #1a4d2e;
|
--color-secondary: oklab(0.6 -0.02 -0.05);
|
||||||
--color-error-text: #fca5a5;
|
--color-secondary-strong: oklab(0.5 -0.02 -0.05);
|
||||||
--color-error-bg: #4a1f1f;
|
--color-success-text: oklab(0.75 -0.12 0.09);
|
||||||
--color-info-text: #bae6fd;
|
--color-success-bg: oklab(0.3 -0.07 0.05);
|
||||||
--color-info-bg: #1e3a5f;
|
--color-error-text: oklab(0.8 0.11 0.05);
|
||||||
--color-danger: #f87171;
|
--color-error-bg: oklab(0.3 0.07 0.03);
|
||||||
--shadow-soft: 0 0 0 #000000;
|
--color-info-text: oklab(0.8 -0.03 -0.10);
|
||||||
}
|
--color-info-bg: oklab(0.3 -0.02 -0.05);
|
||||||
|
--color-danger: oklab(0.7 0.16 0.08);
|
||||||
|
--color-primary: oklab(0.7 -0.05 -0.14);
|
||||||
|
--color-error: oklab(0.8 0.11 0.05);
|
||||||
|
--color-success: oklab(0.75 -0.12 0.09);
|
||||||
|
--color-bg: oklab(0.18 -0.01 -0.03);
|
||||||
|
--color-accent-soft: oklab(0.25 -0.03 -0.08);
|
||||||
|
--shadow-soft: 0 0 0 black;
|
||||||
|
--shadow-xl: 0 10px 40px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
@@ -78,7 +100,6 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color-scheme: light dark;
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -132,6 +153,7 @@ a:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.view-root {
|
.view-root {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -150,6 +172,25 @@ a:focus-visible {
|
|||||||
max-width: 540px;
|
max-width: 540px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.view-root--profile .view-header-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: calc(1200px - 2 * var(--layout-padding));
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-root--profile .theme-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-block--constrained {
|
||||||
|
width: 100%;
|
||||||
|
max-width: calc(1200px - 2 * var(--layout-padding));
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.view-header {
|
.view-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -197,6 +238,7 @@ a:focus-visible {
|
|||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-row button {
|
.button-row button {
|
||||||
@@ -229,6 +271,7 @@ button {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
transition: box-shadow var(--transition-base), background var(--transition-base), border-color var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
@@ -236,49 +279,53 @@ button:disabled {
|
|||||||
filter: opacity(0.6);
|
filter: opacity(0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
output[title="Click to copy"] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--color-accent);
|
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-accent);
|
||||||
color: var(--color-accent-contrast);
|
color: var(--color-accent-contrast);
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled),
|
||||||
|
.btn-primary:focus-visible {
|
||||||
|
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-accent-strong);
|
||||||
|
border-color: var(--color-accent-strong);
|
||||||
box-shadow: var(--shadow-soft);
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
background: var(--color-accent-strong);
|
|
||||||
border-color: var(--color-accent-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-secondary);
|
||||||
color: var(--color-text);
|
|
||||||
border-color: var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
|
||||||
border-color: var(--color-border-strong);
|
|
||||||
background: var(--color-surface-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: var(--color-danger);
|
|
||||||
color: var(--color-accent-contrast);
|
color: var(--color-accent-contrast);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled),
|
||||||
filter: brightness(0.92);
|
.btn-secondary:focus-visible {
|
||||||
|
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-secondary-strong);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Focus-visible outlines for buttons */
|
.btn-danger {
|
||||||
.btn-primary:focus-visible,
|
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-danger);
|
||||||
.btn-secondary:focus-visible,
|
color: var(--color-accent-contrast);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled),
|
||||||
.btn-danger:focus-visible {
|
.btn-danger:focus-visible {
|
||||||
outline: 1px solid var(-webkit-focus-ring-color);
|
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-danger);
|
||||||
|
filter: brightness(0.92);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input:not([type]),
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="search"],
|
input[type="search"],
|
||||||
input[type="email"],
|
input[type="email"],
|
||||||
|
input[type="tel"],
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -291,6 +338,18 @@ select {
|
|||||||
transition: border-color var(--transition-base), box-shadow var(--transition-base);
|
transition: border-color var(--transition-base), box-shadow var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input:not([type]):focus,
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="search"]:focus,
|
||||||
|
input[type="email"]:focus,
|
||||||
|
input[type="tel"]:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -331,10 +390,118 @@ th {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Permission matrix styles */
|
||||||
|
.matrix-wrapper {
|
||||||
|
margin: var(--space-md) 0;
|
||||||
|
padding: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-matrix-grid {
|
||||||
|
display: inline-grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-matrix-grid > * {
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-matrix-grid .grid-head {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-matrix-grid .perm-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-matrix-grid .rotated-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-matrix-grid .rotated-head span {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-heading);
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.center {
|
.center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Utility classes */
|
||||||
|
.muted {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--color-danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 0.2rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
color: var(--color-heading);
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-icon {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-icon:hover {
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
color: var(--color-error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row--center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -353,13 +520,17 @@ th {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
z-index: 1200;
|
z-index: 2000;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
min-width: min(520px, calc(100% - 2rem));
|
min-width: min(520px, calc(100% - 2rem));
|
||||||
max-width: calc(100% - 2rem);
|
max-width: calc(100% - 2rem);
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.global-status.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.global-status .status {
|
.global-status .status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -407,7 +578,7 @@ th {
|
|||||||
|
|
||||||
.device-dialog,
|
.device-dialog,
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--color-surface);
|
background: var(--color-dialog);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
width: min(520px, 100%);
|
width: min(520px, 100%);
|
||||||
@@ -429,8 +600,7 @@ th {
|
|||||||
|
|
||||||
.qr-code {
|
.qr-code {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #fff;
|
background: white;
|
||||||
box-shadow: var(--shadow-soft);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-container,
|
.link-container,
|
||||||
@@ -487,9 +657,18 @@ th {
|
|||||||
|
|
||||||
.record-item.is-current,
|
.record-item.is-current,
|
||||||
.credential-item.current-session,
|
.credential-item.current-session,
|
||||||
|
.session-item.is-current {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background-color: var(--color-surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
.credential-item.is-hovered,
|
.credential-item.is-hovered,
|
||||||
.session-item.is-current,
|
.session-item.is-hovered {
|
||||||
.session-item.is-hovered { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
|
border-color: var(--color-accent);
|
||||||
|
background-color: var(--color-surface-subtle);
|
||||||
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
.credential-item.is-linked-session,
|
.credential-item.is-linked-session,
|
||||||
.session-item.is-linked-credential { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
|
.session-item.is-linked-credential { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
|
||||||
@@ -567,7 +746,7 @@ th {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-card-delete { background: transparent; border: none; color: var(--color-danger); padding: 0.35rem 0.5rem; font-size: 1.05rem; line-height: 1; border-radius: var(--radius-sm); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
.btn-card-delete { background: transparent; border: none; color: var(--color-danger); padding: 0.35rem 0.5rem; font-size: 1.05rem; line-height: 1; border-radius: var(--radius-sm); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
.btn-card-delete:hover:not(:disabled) { background: #fee; }
|
.btn-card-delete:hover:not(:disabled) { filter: brightness(0.85); }
|
||||||
.btn-card-delete:disabled { filter: opacity(0.4); cursor: not-allowed; }
|
.btn-card-delete:disabled { filter: opacity(0.4); cursor: not-allowed; }
|
||||||
|
|
||||||
|
|
||||||
@@ -644,9 +823,6 @@ th {
|
|||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
display: grid;
|
display: grid;
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--color-surface);
|
|
||||||
padding: 1.1rem 1.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-details {
|
.user-details {
|
||||||
@@ -692,8 +868,8 @@ th {
|
|||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
backdrop-filter: blur(.1rem) brightness(0.7);
|
backdrop-filter: blur(.2rem) brightness(0.7);
|
||||||
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
-webkit-backdrop-filter: blur(.2rem) brightness(0.7);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -711,7 +887,7 @@ th {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
background: var(--color-surface);
|
background: var(--color-dialog);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: 0 20px 60px #1e293b;
|
box-shadow: 0 20px 60px #1e293b;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
@@ -731,45 +907,9 @@ th {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global backdrop controlled by api.js ref-counting */
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1099;
|
|
||||||
background: transparent;
|
|
||||||
backdrop-filter: blur(0) brightness(1);
|
|
||||||
-webkit-backdrop-filter: blur(0) brightness(1);
|
|
||||||
pointer-events: none;
|
|
||||||
visibility: hidden;
|
|
||||||
transition: all 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.has-backdrop::before {
|
|
||||||
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
|
|
||||||
backdrop-filter: blur(.2rem) brightness(0.5);
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.has-backdrop {
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#auth-iframe {
|
|
||||||
border: none;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 9999;
|
|
||||||
color-scheme: auto;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slot-machine {
|
.slot-machine {
|
||||||
padding: 0.875rem 1rem;
|
padding: 0.875rem 1rem;
|
||||||
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
|
background: var(--color-surface-hover);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
@@ -790,3 +930,28 @@ body.has-backdrop {
|
|||||||
height: 1.8em;
|
height: 1.8em;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Spinner utilities */
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid var(--color-border);
|
||||||
|
border-top-color: var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner--sm {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner--md {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="message-container">
|
<div class="message-container">
|
||||||
<div class="message-content">
|
<div class="message-content">
|
||||||
<h2>🔒 Access Denied</h2>
|
<h2>{{ icon }} {{ title }}</h2>
|
||||||
|
<p v-if="message" class="error-detail">{{ message }}</p>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button class="btn-secondary" @click="goBack">Back</button>
|
<button class="btn-secondary" @click="goBack">Back</button>
|
||||||
<button class="btn-primary" @click="$emit('reload')">Reload Page</button>
|
<button class="btn-primary" @click="reload">Reload Page</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -13,7 +14,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { goBack } from '@/utils/helpers'
|
import { goBack } from '@/utils/helpers'
|
||||||
|
|
||||||
defineEmits(['reload'])
|
const props = defineProps({
|
||||||
|
title: { type: String, default: 'Access Denied' },
|
||||||
|
icon: { type: String, default: '🔒' },
|
||||||
|
message: { type: String, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -32,10 +41,15 @@ defineEmits(['reload'])
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-content h2 {
|
.message-content h2 {
|
||||||
margin: 0 0 1.5rem;
|
margin: 0 0 1rem;
|
||||||
color: var(--color-heading);
|
color: var(--color-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-content .error-detail {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.message-content .button-row {
|
.message-content .button-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
@@ -5,16 +5,16 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<div
|
<div
|
||||||
v-for="credential in credentials"
|
v-for="credential in credentials"
|
||||||
:key="credential.credential_uuid"
|
:key="credential.credential"
|
||||||
:class="['credential-item', {
|
:class="['credential-item', {
|
||||||
'current-session': credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid,
|
'current-session': credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid,
|
||||||
'is-hovered': hoveredCredentialUuid === credential.credential_uuid,
|
'is-hovered': hoveredCredentialUuid === credential.credential,
|
||||||
'is-linked-session': hoveredSessionCredentialUuid === credential.credential_uuid
|
'is-linked-session': hoveredSessionCredentialUuid === credential.credential
|
||||||
}]"
|
}]"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@mousedown.prevent
|
@mousedown.prevent
|
||||||
@click.capture="handleCardClick"
|
@click.capture="handleCardClick"
|
||||||
@focusin="handleCredentialFocus(credential.credential_uuid)"
|
@focusin="handleCredentialFocus(credential.credential)"
|
||||||
@focusout="handleCredentialBlur($event)"
|
@focusout="handleCredentialBlur($event)"
|
||||||
@keydown="handleItemKeydown($event, credential)"
|
@keydown="handleItemKeydown($event, credential)"
|
||||||
>
|
>
|
||||||
@@ -33,8 +33,8 @@
|
|||||||
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
|
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<span v-if="credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid" class="badge badge-current">Current</span>
|
<span v-if="credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid" class="badge badge-current">Current</span>
|
||||||
<span v-else-if="hoveredCredentialUuid === credential.credential_uuid" class="badge badge-current">Selected</span>
|
<span v-else-if="hoveredCredentialUuid === credential.credential" class="badge badge-current">Selected</span>
|
||||||
<span v-else-if="hoveredSessionCredentialUuid === credential.credential_uuid" class="badge badge-current">Linked</span>
|
<span v-else-if="hoveredSessionCredentialUuid === credential.credential" class="badge badge-current">Linked</span>
|
||||||
<button
|
<button
|
||||||
v-if="allowDelete"
|
v-if="allowDelete"
|
||||||
@click="$emit('delete', credential)"
|
@click="$emit('delete', credential)"
|
||||||
@@ -148,8 +148,8 @@ const getCredentialAuthIcon = (credential) => {
|
|||||||
const info = props.aaguidInfo?.[credential.aaguid]
|
const info = props.aaguidInfo?.[credential.aaguid]
|
||||||
if (!info) return null
|
if (!info) return null
|
||||||
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
|
// Fall back to icon if icon_dark is not available
|
||||||
return info[iconKey] || null
|
return (isDarkMode && info.icon_dark) || info.icon || null
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="view-root host-view" data-view="host-profile">
|
<section class="view-root view-root--wide host-view" data-view="host-profile">
|
||||||
<header class="view-header">
|
<header class="view-header">
|
||||||
<h1>{{ headingTitle }}</h1>
|
<h1>{{ headingTitle }}</h1>
|
||||||
<p class="view-lede">{{ subheading }}</p>
|
<p class="view-lede">{{ subheading }}</p>
|
||||||
@@ -8,11 +8,14 @@
|
|||||||
<section class="section-block" ref="userInfoSection">
|
<section class="section-block" ref="userInfoSection">
|
||||||
<div class="section-body">
|
<div class="section-body">
|
||||||
<UserBasicInfo
|
<UserBasicInfo
|
||||||
v-if="user"
|
v-if="ctx"
|
||||||
:name="user.user_name"
|
:name="ctx.user.display_name"
|
||||||
:visits="user.visits || 0"
|
:avatar-url="authStore.userInfo.user.avatar_url"
|
||||||
:created-at="user.created_at"
|
:visits="authStore.userInfo.user.visits"
|
||||||
:last-seen="user.last_seen"
|
:created-at="authStore.userInfo.user.created_at"
|
||||||
|
:last-seen="authStore.userInfo.user.last_seen"
|
||||||
|
:email="ctx.user.email"
|
||||||
|
:telephone="ctx.user.telephone"
|
||||||
:org-display-name="orgDisplayName"
|
:org-display-name="orgDisplayName"
|
||||||
:role-name="roleDisplayName"
|
:role-name="roleDisplayName"
|
||||||
:can-edit="false"
|
:can-edit="false"
|
||||||
@@ -78,9 +81,9 @@ const currentHost = window.location.host
|
|||||||
const userInfoSection = ref(null)
|
const userInfoSection = ref(null)
|
||||||
const buttonRow = ref(null)
|
const buttonRow = ref(null)
|
||||||
|
|
||||||
const user = computed(() => authStore.userInfo?.user || null)
|
const ctx = computed(() => authStore.userInfo || null)
|
||||||
const orgDisplayName = computed(() => authStore.userInfo?.org?.display_name || '')
|
const orgDisplayName = computed(() => ctx.value?.org?.display_name ?? '')
|
||||||
const roleDisplayName = computed(() => authStore.userInfo?.role?.display_name || '')
|
const roleDisplayName = computed(() => ctx.value?.role?.display_name ?? '')
|
||||||
|
|
||||||
const headingTitle = computed(() => {
|
const headingTitle = computed(() => {
|
||||||
const service = authStore.settings?.rp_name
|
const service = authStore.settings?.rp_name
|
||||||
@@ -125,12 +128,3 @@ const handleButtonRowKeydown = (event) => {
|
|||||||
// Down does nothing (no elements below to navigate to)
|
// Down does nothing (no elements below to navigate to)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.host-view { padding: 3rem 1.5rem 4rem; }
|
|
||||||
.host-actions { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
||||||
.host-actions .button-row { gap: 0.75rem; flex-wrap: wrap; }
|
|
||||||
.host-actions .button-row button { flex: 1 1 0; }
|
|
||||||
.note { margin: 0; color: var(--color-text-muted); }
|
|
||||||
.empty-state { margin: 0; color: var(--color-text-muted); }
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -27,17 +27,12 @@ defineProps({
|
|||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border: 4px solid var(--color-border);
|
border: 3px solid var(--color-border);
|
||||||
border-top: 4px solid var(--color-primary);
|
border-top-color: var(--color-primary);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container p {
|
.loading-container p {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
|
<div class="dialog-overlay" @click="$emit('close')">
|
||||||
<slot />
|
<div ref="dialog" :class="['modal-panel', panelClass]" @keydown="handleDialogKeydown" @click.stop>
|
||||||
</dialog>
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav'
|
import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav'
|
||||||
|
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
// Optional: provide a fallback element to focus if original element is gone
|
// Optional: provide a fallback element to focus if original element is gone
|
||||||
@@ -14,10 +17,12 @@ const props = defineProps({
|
|||||||
// Optional: index to help find next sibling when item is deleted
|
// Optional: index to help find next sibling when item is deleted
|
||||||
focusIndex: { type: Number, default: -1 },
|
focusIndex: { type: Number, default: -1 },
|
||||||
// Optional: selector for finding siblings when restoring focus
|
// Optional: selector for finding siblings when restoring focus
|
||||||
focusSiblingSelector: { type: String, default: '' }
|
focusSiblingSelector: { type: String, default: '' },
|
||||||
|
// Optional: extra class name(s) for the modal panel
|
||||||
|
panelClass: { type: [String, Array, Object], default: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['close'])
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
// Dialog element reference
|
// Dialog element reference
|
||||||
const dialog = ref(null)
|
const dialog = ref(null)
|
||||||
@@ -76,6 +81,13 @@ const restoreFocus = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDialogKeydown = (event) => {
|
const handleDialogKeydown = (event) => {
|
||||||
|
// ESC to close (previously handled by <dialog> natively)
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('close')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const direction = getDirection(event)
|
const direction = getDirection(event)
|
||||||
if (!direction) return
|
if (!direction) return
|
||||||
|
|
||||||
@@ -111,11 +123,11 @@ onMounted(() => {
|
|||||||
// Save currently focused element before modal takes focus
|
// Save currently focused element before modal takes focus
|
||||||
previouslyFocusedElement.value = document.activeElement
|
previouslyFocusedElement.value = document.activeElement
|
||||||
|
|
||||||
// Show the dialog as a modal
|
holdGlobalBackdrop()
|
||||||
|
|
||||||
|
// Focus the most appropriate element
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (dialog.value) {
|
if (dialog.value) {
|
||||||
dialog.value.showModal()
|
|
||||||
|
|
||||||
// Autofocus the most appropriate element:
|
// Autofocus the most appropriate element:
|
||||||
// - For form dialogs (rename, edit): focus first input and select text
|
// - For form dialogs (rename, edit): focus first input and select text
|
||||||
// - For other dialogs: focus primary button (or fallback)
|
// - For other dialogs: focus primary button (or fallback)
|
||||||
@@ -131,14 +143,16 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
releaseGlobalBackdrop()
|
||||||
// Restore focus when modal closes
|
// Restore focus when modal closes
|
||||||
restoreFocus()
|
restoreFocus()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
dialog {
|
.modal-panel {
|
||||||
background: var(--color-surface);
|
background: var(--color-dialog);
|
||||||
|
color: var(--color-text);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: var(--shadow-xl);
|
box-shadow: var(--shadow-xl);
|
||||||
@@ -147,65 +161,36 @@ dialog {
|
|||||||
width: min(500px, 90vw);
|
width: min(500px, 90vw);
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
margin: auto;
|
|
||||||
height: fit-content;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog::backdrop {
|
.modal-panel :deep(.modal-title),
|
||||||
background: transparent;
|
.modal-panel :deep(h3) {
|
||||||
backdrop-filter: blur(.1rem) brightness(0.7);
|
|
||||||
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog :deep(.modal-title),
|
|
||||||
dialog :deep(h3) {
|
|
||||||
margin: 0 0 var(--space-md);
|
margin: 0 0 var(--space-md);
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-heading);
|
color: var(--color-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog :deep(form) {
|
.modal-panel :deep(form) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog :deep(.modal-form) {
|
.modal-panel :deep(.modal-form) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog :deep(.modal-form label) {
|
.modal-panel :deep(.modal-form label) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog :deep(.modal-form input),
|
.modal-panel :deep(.modal-actions) {
|
||||||
dialog :deep(.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 :deep(.modal-form input:focus),
|
|
||||||
dialog :deep(.modal-form textarea:focus) {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
box-shadow: 0 0 0 2px #c7d2fe;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog :deep(.modal-actions) {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
|||||||
@@ -84,12 +84,4 @@ function handleCancel() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
|
||||||
color: var(--color-danger-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.small {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="rootTag"
|
||||||
|
v-bind="rootAttrs"
|
||||||
|
class="profile-picture"
|
||||||
|
:class="{ 'profile-picture-btn': clickable }"
|
||||||
|
:style="pictureStyle"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="showPicture"
|
||||||
|
:key="`${src || 'none'}:${renderVersion}`"
|
||||||
|
:src="src"
|
||||||
|
alt=""
|
||||||
|
class="profile-picture-image"
|
||||||
|
@error="handleError"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
:src="profileGeneric"
|
||||||
|
alt=""
|
||||||
|
class="profile-picture-fallback"
|
||||||
|
/>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import profileGeneric from '@/assets/profile-generic.svg'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
src: { type: String, default: null },
|
||||||
|
clickable: { type: Boolean, default: false },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
renderVersion: { type: [Number, String], default: 0 },
|
||||||
|
width: { type: String, default: '3rem' },
|
||||||
|
height: { type: String, default: '3rem' },
|
||||||
|
radius: { type: String, default: '0.9rem' },
|
||||||
|
fit: { type: String, default: 'cover' },
|
||||||
|
filter: { type: String, default: 'none' },
|
||||||
|
fallbackSize: { type: String, default: '2em' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['click'])
|
||||||
|
const pictureAvailable = ref(true)
|
||||||
|
|
||||||
|
const rootTag = computed(() => (props.clickable ? 'button' : 'div'))
|
||||||
|
const showPicture = computed(() => !!props.src && pictureAvailable.value)
|
||||||
|
const pictureStyle = computed(() => ({
|
||||||
|
'--profile-picture-width': props.width,
|
||||||
|
'--profile-picture-height': props.height,
|
||||||
|
'--profile-picture-radius': props.radius,
|
||||||
|
'--profile-picture-fit': props.fit,
|
||||||
|
'--profile-picture-filter': props.filter,
|
||||||
|
'--profile-picture-fallback-size': props.fallbackSize
|
||||||
|
}))
|
||||||
|
const rootAttrs = computed(() => {
|
||||||
|
if (!props.clickable) return { title: props.title || undefined }
|
||||||
|
return {
|
||||||
|
type: 'button',
|
||||||
|
disabled: props.loading,
|
||||||
|
title: props.title || undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.src, () => {
|
||||||
|
pictureAvailable.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
pictureAvailable.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!props.clickable || props.loading) return
|
||||||
|
emit('click')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.profile-picture {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--profile-picture-width);
|
||||||
|
height: var(--profile-picture-height);
|
||||||
|
font-size: var(--profile-picture-fallback-size);
|
||||||
|
line-height: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--profile-picture-radius);
|
||||||
|
background: transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture-btn {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture-btn:disabled {
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: var(--profile-picture-fit);
|
||||||
|
display: block;
|
||||||
|
filter: var(--profile-picture-filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture-fallback {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
<template>
|
||||||
|
<Modal panel-class="modal-panel--avatar" @close="closeEditor">
|
||||||
|
<h3>{{ title }}</h3>
|
||||||
|
<input
|
||||||
|
ref="pictureInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="profile-picture-editor-input"
|
||||||
|
:disabled="saving"
|
||||||
|
@change="handlePictureSelected"
|
||||||
|
/>
|
||||||
|
<div ref="picturePreview" class="profile-picture-editor-preview" :style="previewStyle">
|
||||||
|
<img
|
||||||
|
v-if="editorImageUrl && displayMetrics"
|
||||||
|
:src="editorImageUrl"
|
||||||
|
alt=""
|
||||||
|
class="profile-picture-editor-image"
|
||||||
|
:style="editorImageStyle"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-if="editorImageUrl && displayMetrics"
|
||||||
|
:src="editorImageUrl"
|
||||||
|
alt=""
|
||||||
|
class="profile-picture-editor-image profile-picture-editor-image--overlay"
|
||||||
|
:style="editorOverlayStyle"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="editorImageUrl && displayMetrics"
|
||||||
|
class="profile-picture-editor-crop"
|
||||||
|
:style="cropBoxStyle"
|
||||||
|
@pointerdown="startMove"
|
||||||
|
>
|
||||||
|
<div class="profile-picture-editor-guides" aria-hidden="true">
|
||||||
|
<div class="profile-picture-editor-guide profile-picture-editor-guide--circle"></div>
|
||||||
|
<div class="profile-picture-editor-guide profile-picture-editor-guide--eyes"></div>
|
||||||
|
<div class="profile-picture-editor-guide profile-picture-editor-guide--cheek-left"></div>
|
||||||
|
<div class="profile-picture-editor-guide profile-picture-editor-guide--cheek-right"></div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="profile-picture-editor-handle profile-picture-editor-handle--nw"
|
||||||
|
@pointerdown.stop="startResize($event, 'nw')"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="profile-picture-editor-handle profile-picture-editor-handle--ne"
|
||||||
|
@pointerdown.stop="startResize($event, 'ne')"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="profile-picture-editor-handle profile-picture-editor-handle--sw"
|
||||||
|
@pointerdown.stop="startResize($event, 'sw')"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="profile-picture-editor-handle profile-picture-editor-handle--se"
|
||||||
|
@pointerdown.stop="startResize($event, 'se')"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<ProfilePicture
|
||||||
|
v-else
|
||||||
|
class="profile-picture-editor-trigger"
|
||||||
|
:src="pictureUrl"
|
||||||
|
:render-version="renderVersion"
|
||||||
|
clickable
|
||||||
|
:loading="saving"
|
||||||
|
title="Choose profile picture"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
radius="0"
|
||||||
|
fit="contain"
|
||||||
|
fallback-size="5rem"
|
||||||
|
@click="triggerPictureSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="errorMessage" class="error small">{{ errorMessage }}</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" :disabled="saving" @click="closeEditor">Back</button>
|
||||||
|
<button
|
||||||
|
v-if="!editorImageUrl && pictureUrl"
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="removePicture"
|
||||||
|
>Delete</button>
|
||||||
|
<button
|
||||||
|
v-if="editorImageUrl"
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="savePicture"
|
||||||
|
>Save</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { apiJson } from 'paskia'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import Modal from '@/components/Modal.vue'
|
||||||
|
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||||
|
|
||||||
|
const AVATAR_UPLOAD_SIZE = 720
|
||||||
|
const MIN_CROP_SIZE = 36
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
endpoint: { type: String, required: true },
|
||||||
|
pictureUrl: { type: String, default: null },
|
||||||
|
renderVersion: { type: [Number, String], default: 0 },
|
||||||
|
title: { type: String, default: 'Profile Picture' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'updated'])
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const pictureInput = ref(null)
|
||||||
|
const picturePreview = ref(null)
|
||||||
|
const editorImage = ref(null)
|
||||||
|
const editorImageUrl = ref('')
|
||||||
|
const previewObjectUrl = ref(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const cropRect = reactive({ x: 0, y: 0, size: 0 })
|
||||||
|
const previewRect = reactive({ width: 0, height: 0 })
|
||||||
|
const viewportSize = reactive({ width: 0, height: 0 })
|
||||||
|
let dragState = null
|
||||||
|
let previewObserver = null
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
viewportSize.width = window.innerWidth
|
||||||
|
viewportSize.height = window.innerHeight
|
||||||
|
window.addEventListener('pointermove', handlePointerMove)
|
||||||
|
window.addEventListener('pointerup', endPointerInteraction)
|
||||||
|
window.addEventListener('resize', syncPreviewRect)
|
||||||
|
await nextTick()
|
||||||
|
syncPreviewRect()
|
||||||
|
if (picturePreview.value && typeof ResizeObserver !== 'undefined') {
|
||||||
|
previewObserver = new ResizeObserver(() => syncPreviewRect())
|
||||||
|
previewObserver.observe(picturePreview.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('pointermove', handlePointerMove)
|
||||||
|
window.removeEventListener('pointerup', endPointerInteraction)
|
||||||
|
window.removeEventListener('resize', syncPreviewRect)
|
||||||
|
previewObserver?.disconnect()
|
||||||
|
clearPreviewObjectUrl()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(editorImage, async (image) => {
|
||||||
|
if (!image) return
|
||||||
|
await nextTick()
|
||||||
|
syncPreviewRect()
|
||||||
|
initializeCrop()
|
||||||
|
})
|
||||||
|
|
||||||
|
const clearPreviewObjectUrl = () => {
|
||||||
|
if (!previewObjectUrl.value) return
|
||||||
|
URL.revokeObjectURL(previewObjectUrl.value)
|
||||||
|
previewObjectUrl.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetEditor = () => {
|
||||||
|
clearPreviewObjectUrl()
|
||||||
|
editorImage.value = null
|
||||||
|
editorImageUrl.value = ''
|
||||||
|
cropRect.x = 0
|
||||||
|
cropRect.y = 0
|
||||||
|
cropRect.size = 0
|
||||||
|
errorMessage.value = ''
|
||||||
|
if (pictureInput.value) pictureInput.value.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncPreviewRect = () => {
|
||||||
|
viewportSize.width = window.innerWidth
|
||||||
|
viewportSize.height = window.innerHeight
|
||||||
|
const element = picturePreview.value
|
||||||
|
if (!element) return
|
||||||
|
previewRect.width = element.clientWidth
|
||||||
|
previewRect.height = element.clientHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewStyle = computed(() => {
|
||||||
|
const image = editorImage.value
|
||||||
|
if (!image) {
|
||||||
|
const size = Math.min(viewportSize.width * 0.72, viewportSize.height * 0.42, 352)
|
||||||
|
return {
|
||||||
|
width: `${Math.max(160, Math.round(size))}px`,
|
||||||
|
height: `${Math.max(160, Math.round(size))}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWidth = Math.min(viewportSize.width * 0.88, 928)
|
||||||
|
const maxHeight = Math.min(viewportSize.height * 0.62, 620)
|
||||||
|
const scale = Math.min(maxWidth / image.naturalWidth, maxHeight / image.naturalHeight)
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: `${Math.max(1, Math.round(image.naturalWidth * scale))}px`,
|
||||||
|
height: `${Math.max(1, Math.round(image.naturalHeight * scale))}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayMetrics = computed(() => {
|
||||||
|
const image = editorImage.value
|
||||||
|
if (!image || !previewRect.width || !previewRect.height) return null
|
||||||
|
const scale = Math.min(previewRect.width / image.naturalWidth, previewRect.height / image.naturalHeight)
|
||||||
|
const width = image.naturalWidth * scale
|
||||||
|
const height = image.naturalHeight * scale
|
||||||
|
return {
|
||||||
|
x: (previewRect.width - width) / 2,
|
||||||
|
y: (previewRect.height - height) / 2,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const editorImageStyle = computed(() => {
|
||||||
|
const metrics = displayMetrics.value
|
||||||
|
if (!metrics) return null
|
||||||
|
return {
|
||||||
|
width: `${metrics.width}px`,
|
||||||
|
height: `${metrics.height}px`,
|
||||||
|
left: `${metrics.x}px`,
|
||||||
|
top: `${metrics.y}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const editorOverlayStyle = computed(() => {
|
||||||
|
const metrics = displayMetrics.value
|
||||||
|
if (!metrics || !cropRect.size) return editorImageStyle.value
|
||||||
|
|
||||||
|
const left = cropRect.x
|
||||||
|
const top = cropRect.y
|
||||||
|
const right = cropRect.x + cropRect.size
|
||||||
|
const bottom = cropRect.y + cropRect.size
|
||||||
|
|
||||||
|
return {
|
||||||
|
...editorImageStyle.value,
|
||||||
|
clipPath: `polygon(evenodd, 0 0, 100% 0, 100% 100%, 0 100%, 0 0, ${left}px ${top}px, ${left}px ${bottom}px, ${right}px ${bottom}px, ${right}px ${top}px, ${left}px ${top}px)`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const cropBoxStyle = computed(() => {
|
||||||
|
const metrics = displayMetrics.value
|
||||||
|
if (!metrics || !cropRect.size) return null
|
||||||
|
return {
|
||||||
|
left: `${metrics.x + cropRect.x}px`,
|
||||||
|
top: `${metrics.y + cropRect.y}px`,
|
||||||
|
width: `${cropRect.size}px`,
|
||||||
|
height: `${cropRect.size}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const initializeCrop = () => {
|
||||||
|
const metrics = displayMetrics.value
|
||||||
|
if (!metrics) return
|
||||||
|
const size = Math.min(metrics.width, metrics.height)
|
||||||
|
cropRect.size = size
|
||||||
|
cropRect.x = (metrics.width - size) / 2
|
||||||
|
cropRect.y = (metrics.height - size) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerPictureSelect = () => {
|
||||||
|
pictureInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePictureSelected = async (event) => {
|
||||||
|
const nextFile = event.target.files?.[0] || null
|
||||||
|
resetEditor()
|
||||||
|
if (!nextFile) return
|
||||||
|
|
||||||
|
previewObjectUrl.value = URL.createObjectURL(nextFile)
|
||||||
|
editorImageUrl.value = previewObjectUrl.value
|
||||||
|
const image = new Image()
|
||||||
|
image.decoding = 'async'
|
||||||
|
image.src = editorImageUrl.value
|
||||||
|
try {
|
||||||
|
await image.decode()
|
||||||
|
editorImage.value = image
|
||||||
|
} catch {
|
||||||
|
errorMessage.value = 'Failed to load image'
|
||||||
|
resetEditor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMove = (event) => {
|
||||||
|
if (!displayMetrics.value || saving.value) return
|
||||||
|
event.preventDefault()
|
||||||
|
dragState = {
|
||||||
|
mode: 'move',
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
initialX: cropRect.x,
|
||||||
|
initialY: cropRect.y,
|
||||||
|
initialSize: cropRect.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startResize = (event, handle) => {
|
||||||
|
if (!displayMetrics.value || saving.value) return
|
||||||
|
event.preventDefault()
|
||||||
|
dragState = {
|
||||||
|
mode: 'resize',
|
||||||
|
handle,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
initialX: cropRect.x,
|
||||||
|
initialY: cropRect.y,
|
||||||
|
initialSize: cropRect.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerMove = (event) => {
|
||||||
|
if (!dragState) return
|
||||||
|
const metrics = displayMetrics.value
|
||||||
|
if (!metrics) return
|
||||||
|
|
||||||
|
const dx = event.clientX - dragState.startX
|
||||||
|
const dy = event.clientY - dragState.startY
|
||||||
|
|
||||||
|
if (dragState.mode === 'move') {
|
||||||
|
cropRect.x = Math.max(0, Math.min(metrics.width - dragState.initialSize, dragState.initialX + dx))
|
||||||
|
cropRect.y = Math.max(0, Math.min(metrics.height - dragState.initialSize, dragState.initialY + dy))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const directionMap = {
|
||||||
|
nw: { deltaX: -1, deltaY: -1 },
|
||||||
|
ne: { deltaX: 1, deltaY: -1 },
|
||||||
|
sw: { deltaX: -1, deltaY: 1 },
|
||||||
|
se: { deltaX: 1, deltaY: 1 }
|
||||||
|
}
|
||||||
|
const direction = directionMap[dragState.handle]
|
||||||
|
if (!direction) return
|
||||||
|
|
||||||
|
const delta = Math.max(dx * direction.deltaX, dy * direction.deltaY)
|
||||||
|
const nextSize = Math.max(
|
||||||
|
MIN_CROP_SIZE,
|
||||||
|
Math.min(getResizeLimit(metrics, dragState), dragState.initialSize + delta)
|
||||||
|
)
|
||||||
|
|
||||||
|
applyResize(dragState, nextSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
const endPointerInteraction = () => {
|
||||||
|
dragState = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getResizeLimit = (metrics, state) => {
|
||||||
|
const { initialX, initialY, initialSize, handle } = state
|
||||||
|
|
||||||
|
if (handle === 'nw') return Math.min(initialX + initialSize, initialY + initialSize)
|
||||||
|
if (handle === 'ne') return Math.min(metrics.width - initialX, initialY + initialSize)
|
||||||
|
if (handle === 'sw') return Math.min(initialX + initialSize, metrics.height - initialY)
|
||||||
|
return Math.min(metrics.width - initialX, metrics.height - initialY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyResize = (state, size) => {
|
||||||
|
const { initialX, initialY, initialSize, handle } = state
|
||||||
|
|
||||||
|
if (handle === 'nw') {
|
||||||
|
cropRect.x = initialX + initialSize - size
|
||||||
|
cropRect.y = initialY + initialSize - size
|
||||||
|
cropRect.size = size
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handle === 'ne') {
|
||||||
|
cropRect.x = initialX
|
||||||
|
cropRect.y = initialY + initialSize - size
|
||||||
|
cropRect.size = size
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handle === 'sw') {
|
||||||
|
cropRect.x = initialX + initialSize - size
|
||||||
|
cropRect.y = initialY
|
||||||
|
cropRect.size = size
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cropRect.x = initialX
|
||||||
|
cropRect.y = initialY
|
||||||
|
cropRect.size = size
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPictureBlob = async () => {
|
||||||
|
const image = editorImage.value
|
||||||
|
if (!image) throw new Error('No image selected')
|
||||||
|
const metrics = displayMetrics.value
|
||||||
|
if (!metrics || !cropRect.size) throw new Error('Crop selection unavailable')
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = AVATAR_UPLOAD_SIZE
|
||||||
|
canvas.height = AVATAR_UPLOAD_SIZE
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (!context) throw new Error('Canvas unavailable')
|
||||||
|
|
||||||
|
const sourceScale = image.naturalWidth / metrics.width
|
||||||
|
const sourceX = cropRect.x * sourceScale
|
||||||
|
const sourceY = cropRect.y * sourceScale
|
||||||
|
const sourceSize = cropRect.size * sourceScale
|
||||||
|
context.drawImage(image, sourceX, sourceY, sourceSize, sourceSize, 0, 0, AVATAR_UPLOAD_SIZE, AVATAR_UPLOAD_SIZE)
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
reject(new Error('Failed to export cropped picture'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve(blob)
|
||||||
|
}, 'image/webp', 0.9)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadPictureFromCache = async () => {
|
||||||
|
const response = await fetch(props.endpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
cache: 'reload'
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Failed to refresh profile picture')
|
||||||
|
}
|
||||||
|
|
||||||
|
const savePicture = async () => {
|
||||||
|
try {
|
||||||
|
saving.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
const blob = await renderPictureBlob()
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', blob, 'profile.webp')
|
||||||
|
await apiJson(props.endpoint, { method: 'PUT', body: formData })
|
||||||
|
await reloadPictureFromCache()
|
||||||
|
authStore.showMessage('Profile picture updated.', 'success', 3000)
|
||||||
|
emit('updated')
|
||||||
|
closeEditor()
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message || 'Failed to update profile picture'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removePicture = async () => {
|
||||||
|
try {
|
||||||
|
saving.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
await apiJson(props.endpoint, { method: 'DELETE' })
|
||||||
|
authStore.showMessage('Profile picture removed.', 'success', 3000)
|
||||||
|
emit('updated')
|
||||||
|
closeEditor()
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message || 'Failed to remove profile picture'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeEditor = () => {
|
||||||
|
resetEditor()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.profile-picture-editor-input { display: none; }
|
||||||
|
.profile-picture-editor-preview { position: relative; display: flex; justify-content: center; align-items: center; width: auto; max-width: min(58rem, 88vw); min-height: 0; margin: 0 auto; overflow: visible; }
|
||||||
|
.profile-picture-editor-trigger { min-width: 0; }
|
||||||
|
.profile-picture-editor-image { position: absolute; user-select: none; pointer-events: none; object-fit: contain; }
|
||||||
|
.profile-picture-editor-image--overlay { filter: grayscale(0.45) saturate(0.7) brightness(0.68); }
|
||||||
|
.profile-picture-editor-crop { position: absolute; border: 2px solid white; cursor: move; touch-action: none; }
|
||||||
|
.profile-picture-editor-guides { position: absolute; inset: 0; pointer-events: none; }
|
||||||
|
.profile-picture-editor-guide { position: absolute; border-color: rgba(255, 255, 255, 0.52); }
|
||||||
|
.profile-picture-editor-guide--circle { inset: 0; border: 1.5px solid rgba(255, 255, 255, 0.62); border-radius: 999px; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18); }
|
||||||
|
.profile-picture-editor-guide--eyes { left: 18%; right: 18%; top: 38%; border-top: 1.5px solid rgba(255, 255, 255, 0.56); }
|
||||||
|
.profile-picture-editor-guide--cheek-left { top: 24%; bottom: 18%; left: 24%; border-left: 1.5px solid rgba(255, 255, 255, 0.48); }
|
||||||
|
.profile-picture-editor-guide--cheek-right { top: 24%; bottom: 18%; right: 24%; border-right: 1.5px solid rgba(255, 255, 255, 0.48); }
|
||||||
|
.profile-picture-editor-handle { position: absolute; width: 1.1rem; height: 1.1rem; border-radius: 999px; border: 2px solid white; background: var(--color-accent); padding: 0; }
|
||||||
|
.profile-picture-editor-handle--nw { left: -0.55rem; top: -0.55rem; cursor: nwse-resize; }
|
||||||
|
.profile-picture-editor-handle--ne { right: -0.55rem; top: -0.55rem; cursor: nesw-resize; }
|
||||||
|
.profile-picture-editor-handle--sw { left: -0.55rem; bottom: -0.55rem; cursor: nesw-resize; }
|
||||||
|
.profile-picture-editor-handle--se { right: -0.55rem; bottom: -0.55rem; cursor: nwse-resize; }
|
||||||
|
:deep(.modal-panel--avatar) { width: fit-content; max-width: min(58rem, 94vw); }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.profile-picture-editor-preview { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,23 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="view-root" data-view="profile">
|
<section class="view-root view-root--profile" data-view="profile">
|
||||||
<header class="view-header">
|
<div class="view-header-wrapper">
|
||||||
<h1>User Profile</h1>
|
<div class="theme-toggle">
|
||||||
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
<ThemeSelector />
|
||||||
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p>
|
</div>
|
||||||
</header>
|
<header class="view-header">
|
||||||
|
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
||||||
|
<p class="view-lede">Account dashboard to manage your profile and authentications.</p>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="section-block" ref="userInfoSection">
|
<section class="section-block section-block--constrained" ref="userInfoSection">
|
||||||
<UserBasicInfo
|
<UserBasicInfo
|
||||||
v-if="authStore.userInfo?.user"
|
v-if="authStore.userInfo?.user"
|
||||||
ref="userBasicInfo"
|
ref="userBasicInfo"
|
||||||
:name="authStore.userInfo.user.user_name"
|
:name="authStore.userInfo.user.display_name"
|
||||||
:visits="authStore.userInfo.user.visits || 0"
|
:avatar-url="authStore.userInfo.user.avatar_url"
|
||||||
|
:avatar-render-version="avatarRenderVersion"
|
||||||
|
avatar-clickable
|
||||||
|
:email="authStore.userInfo.user.email"
|
||||||
|
:preferred_username="authStore.userInfo.user.preferred_username"
|
||||||
|
:telephone="authStore.userInfo.user.telephone"
|
||||||
|
:visits="authStore.userInfo.user.visits"
|
||||||
:created-at="authStore.userInfo.user.created_at"
|
:created-at="authStore.userInfo.user.created_at"
|
||||||
:last-seen="authStore.userInfo.user.last_seen"
|
:last-seen="authStore.userInfo.user.last_seen"
|
||||||
:loading="authStore.isLoading"
|
:loading="authStore.isLoading"
|
||||||
update-endpoint="/auth/api/user/display-name"
|
:org-display-name="authStore.userInfo.org.display_name"
|
||||||
|
:role-name="authStore.userInfo.role.display_name"
|
||||||
|
update-endpoint="/auth/api/user/info"
|
||||||
@saved="authStore.loadUserInfo()"
|
@saved="authStore.loadUserInfo()"
|
||||||
@edit-name="openNameDialog"
|
@avatar-click="openAvatarDialog"
|
||||||
|
@edit="openEditDialog"
|
||||||
@keydown="handleUserInfoKeydown"
|
@keydown="handleUserInfoKeydown"
|
||||||
>
|
>
|
||||||
<div class="remote-auth-inline">
|
<div class="remote-auth-inline">
|
||||||
@@ -31,11 +44,11 @@
|
|||||||
@device-info-visible="showDeviceInfo = $event"
|
@device-info-visible="showDeviceInfo = $event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="remote-auth-description">Provided by another device requesting remote auth.</p>
|
<p class="remote-auth-description">Login from another device</p>
|
||||||
</UserBasicInfo>
|
</UserBasicInfo>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section-block">
|
<section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Your Passkeys</h2>
|
<h2>Your Passkeys</h2>
|
||||||
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
|
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
|
||||||
@@ -43,11 +56,11 @@
|
|||||||
<div class="section-body">
|
<div class="section-body">
|
||||||
<CredentialList
|
<CredentialList
|
||||||
ref="credentialList"
|
ref="credentialList"
|
||||||
:credentials="authStore.userInfo?.credentials || []"
|
:credentials="credentials"
|
||||||
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
|
:aaguid-info="authStore.userInfo.aaguid_info"
|
||||||
:loading="authStore.isLoading"
|
:loading="authStore.isLoading"
|
||||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||||
:hovered-session-credential-uuid="hoveredSession?.credential_uuid"
|
:hovered-session-credential-uuid="hoveredSession?.credential"
|
||||||
:navigation-disabled="hasActiveModal"
|
:navigation-disabled="hasActiveModal"
|
||||||
allow-delete
|
allow-delete
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
@@ -67,25 +80,14 @@
|
|||||||
:terminating-sessions="terminatingSessions"
|
:terminating-sessions="terminatingSessions"
|
||||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||||
:navigation-disabled="hasActiveModal"
|
:navigation-disabled="hasActiveModal"
|
||||||
|
:section-class="useWideLayout ? '' : 'section-block--constrained'"
|
||||||
@terminate="terminateSession"
|
@terminate="terminateSession"
|
||||||
@session-hover="hoveredSession = $event"
|
@session-hover="hoveredSession = $event"
|
||||||
@navigate-out="handleSessionNavigateOut"
|
@navigate-out="handleSessionNavigateOut"
|
||||||
section-description="You are currently signed in to the following sessions. If you don't recognize something, consider deleting not only the session but the associated passkey you suspect is compromised, as only this terminates all linked sessions and prevents logging in again."
|
section-description="You are currently signed in to the following sessions. If you don't recognize something, consider deleting not only the session but the associated passkey you suspect is compromised, as only this terminates all linked sessions and prevents logging in again."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal v-if="showNameDialog" @close="showNameDialog = false">
|
<section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
|
||||||
<h3>Edit Display Name</h3>
|
|
||||||
<form @submit.prevent="saveName" class="modal-form">
|
|
||||||
<NameEditForm
|
|
||||||
label="Display Name"
|
|
||||||
v-model="newName"
|
|
||||||
:busy="saving"
|
|
||||||
@cancel="showNameDialog = false"
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<section class="section-block">
|
|
||||||
<div class="button-row" ref="logoutButtons">
|
<div class="button-row" ref="logoutButtons">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -101,9 +103,47 @@
|
|||||||
<button @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">All</button>
|
<button @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">All</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
|
<div class="logout-footer">
|
||||||
<p class="logout-note" v-else><strong>Logout</strong> this session on {{ currentSessionHost }}, or <strong>All</strong> sessions across all sites and devices for {{ rpName }}. You'll need to log in again with your passkey afterwards.</p>
|
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
|
||||||
|
<p class="logout-note" v-else><strong>Logout</strong> this session on {{ currentSessionHost }}, or <strong>All</strong> sessions across all sites and devices for {{ rpName }}.</p>
|
||||||
|
<a class="paskia-version" href="https://git.zi.fi/leovasanko/paskia" target="_blank" rel="noopener noreferrer">Paskia {{ paskiaVersion }}</a>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<Modal v-if="showEditDialog" @close="showEditDialog = false">
|
||||||
|
<h3>Edit Profile</h3>
|
||||||
|
<form @submit.prevent="saveProfile" class="modal-form">
|
||||||
|
<div class="profile-edit-form">
|
||||||
|
<label for="edit-display-name">Display Name
|
||||||
|
<input id="edit-display-name" type="text" v-model="editName" :disabled="saving" required />
|
||||||
|
</label>
|
||||||
|
<label for="edit-email">Email
|
||||||
|
<input id="edit-email" type="email" v-model="editEmail" :disabled="saving" />
|
||||||
|
</label>
|
||||||
|
<label for="edit-username">Preferred Username
|
||||||
|
<input id="edit-username" type="text" v-model="editUsername" :disabled="saving" placeholder="username" />
|
||||||
|
</label>
|
||||||
|
<label for="edit-telephone">Telephone
|
||||||
|
<input id="edit-telephone" type="tel" v-model="editTelephone" :disabled="saving" />
|
||||||
|
</label>
|
||||||
|
<div v-if="editError" class="error small">{{ editError }}</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" @click="showEditDialog = false" :disabled="saving">Cancel</button>
|
||||||
|
<button type="submit" class="btn-primary" :disabled="saving" data-nav-primary>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ProfilePictureEditorModal
|
||||||
|
v-if="showAvatarDialog && currentAvatarEndpoint"
|
||||||
|
:endpoint="currentAvatarEndpoint"
|
||||||
|
:picture-url="authStore.userInfo?.user?.avatar_url"
|
||||||
|
:render-version="avatarRenderVersion"
|
||||||
|
@close="closeAvatarDialog"
|
||||||
|
@updated="handleProfilePictureUpdated"
|
||||||
|
/>
|
||||||
|
|
||||||
<RegistrationLinkModal
|
<RegistrationLinkModal
|
||||||
v-if="showRegLink"
|
v-if="showRegLink"
|
||||||
endpoint="/auth/api/user/create-link"
|
endpoint="/auth/api/user/create-link"
|
||||||
@@ -117,9 +157,10 @@
|
|||||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
||||||
import CredentialList from '@/components/CredentialList.vue'
|
import CredentialList from '@/components/CredentialList.vue'
|
||||||
|
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
|
||||||
|
import ThemeSelector from '@/components/ThemeSelector.vue'
|
||||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
import NameEditForm from '@/components/NameEditForm.vue'
|
|
||||||
import SessionList from '@/components/SessionList.vue'
|
import SessionList from '@/components/SessionList.vue'
|
||||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||||
import RemoteAuthPermit from '@/components/RemoteAuthPermit.vue'
|
import RemoteAuthPermit from '@/components/RemoteAuthPermit.vue'
|
||||||
@@ -127,15 +168,21 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
||||||
import passkey from '@/utils/passkey'
|
import passkey from '@/utils/passkey'
|
||||||
import { goBack } from '@/utils/helpers'
|
import { goBack } from '@/utils/helpers'
|
||||||
import { apiJson } from '@/utils/api'
|
import { apiJson } from 'paskia'
|
||||||
import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@/utils/keynav'
|
import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@/utils/keynav'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const updateInterval = ref(null)
|
const updateInterval = ref(null)
|
||||||
const showNameDialog = ref(false)
|
const showEditDialog = ref(false)
|
||||||
|
const showAvatarDialog = ref(false)
|
||||||
const showRegLink = ref(false)
|
const showRegLink = ref(false)
|
||||||
const newName = ref('')
|
const editName = ref('')
|
||||||
|
const editEmail = ref('')
|
||||||
|
const editUsername = ref('')
|
||||||
|
const editTelephone = ref('')
|
||||||
|
const avatarRenderVersion = ref(0)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const editError = ref('')
|
||||||
const hoveredCredentialUuid = ref(null)
|
const hoveredCredentialUuid = ref(null)
|
||||||
const hoveredSession = ref(null)
|
const hoveredSession = ref(null)
|
||||||
const showDeviceInfo = ref(false)
|
const showDeviceInfo = ref(false)
|
||||||
@@ -145,19 +192,49 @@ const credentialButtons = ref(null)
|
|||||||
const sessionList = ref(null)
|
const sessionList = ref(null)
|
||||||
const logoutButtons = ref(null)
|
const logoutButtons = ref(null)
|
||||||
const breadcrumbs = ref(null)
|
const breadcrumbs = ref(null)
|
||||||
const userBasicInfo = ref(null)
|
|
||||||
const userInfoSection = ref(null)
|
const userInfoSection = ref(null)
|
||||||
|
|
||||||
// Check if any modal/dialog is open (blocks arrow key navigation)
|
// Check if any modal/dialog is open (blocks arrow key navigation)
|
||||||
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value)
|
const hasActiveModal = computed(() => showEditDialog.value || showAvatarDialog.value || showRegLink.value)
|
||||||
|
|
||||||
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
|
watch(showEditDialog, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const user = authStore.userInfo.user
|
||||||
|
editName.value = user.display_name ?? ''
|
||||||
|
editEmail.value = user.email ?? ''
|
||||||
|
editUsername.value = user.preferred_username ?? ''
|
||||||
|
editTelephone.value = user.telephone ?? ''
|
||||||
|
editError.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) })
|
onUnmounted(() => {
|
||||||
|
if (updateInterval.value) clearInterval(updateInterval.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentAvatarEndpoint = computed(() => {
|
||||||
|
const userUuid = authStore.userInfo?.user?.uuid
|
||||||
|
if (!userUuid) return null
|
||||||
|
return `/auth/api/user/${userUuid}/profile.webp`
|
||||||
|
})
|
||||||
|
|
||||||
|
const openAvatarDialog = () => {
|
||||||
|
showAvatarDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeAvatarDialog = () => {
|
||||||
|
showAvatarDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProfilePictureUpdated = async () => {
|
||||||
|
await authStore.loadUserInfo()
|
||||||
|
avatarRenderVersion.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
const addNewCredential = async () => {
|
const addNewCredential = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -206,7 +283,7 @@ const handleBreadcrumbKeydown = (event) => {
|
|||||||
if (direction === 'down') {
|
if (direction === 'down') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
// Move to user info section - always focus edit button first
|
// Move to user info section - always focus edit button first
|
||||||
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
|
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
|
||||||
}
|
}
|
||||||
// ArrowUp at the top does nothing
|
// ArrowUp at the top does nothing
|
||||||
}
|
}
|
||||||
@@ -218,7 +295,7 @@ const handleUserInfoKeydown = (event) => {
|
|||||||
if (!direction) return
|
if (!direction) return
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const itemSelector = '.mini-btn, .pairing-input'
|
const itemSelector = '.user-picture-btn, .mini-btn, .pairing-input'
|
||||||
|
|
||||||
if (direction === 'left' || direction === 'right') {
|
if (direction === 'left' || direction === 'right') {
|
||||||
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
|
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
|
||||||
@@ -239,7 +316,7 @@ const handleCredentialNavigateOut = (direction) => {
|
|||||||
focusPreferredButton(credentialButtons.value)
|
focusPreferredButton(credentialButtons.value)
|
||||||
} else if (direction === 'up' || direction === 'left') {
|
} else if (direction === 'up' || direction === 'left') {
|
||||||
// Focus user info section - always focus edit button first
|
// Focus user info section - always focus edit button first
|
||||||
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
|
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +369,7 @@ const handleLogoutButtonKeydown = (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (credential) => {
|
const handleDelete = async (credential) => {
|
||||||
const credentialId = credential?.credential_uuid
|
const credentialId = credential?.credential
|
||||||
if (!credentialId) return
|
if (!credentialId) return
|
||||||
try {
|
try {
|
||||||
await authStore.deleteCredential(credentialId)
|
await authStore.deleteCredential(credentialId)
|
||||||
@@ -301,56 +378,96 @@ const handleDelete = async (credential) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
|
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
|
||||||
const sessions = computed(() => authStore.userInfo?.sessions || [])
|
const paskiaVersion = computed(() => authStore.settings?.version || '')
|
||||||
|
const sessions = computed(() => authStore.userInfo.sessions)
|
||||||
const currentSessionHost = computed(() => {
|
const currentSessionHost = computed(() => {
|
||||||
const currentSession = sessions.value.find(session => session.is_current)
|
const currentSession = Object.values(sessions.value).find(session => session.is_current)
|
||||||
return currentSession?.host || 'this host'
|
return currentSession?.host || 'this host'
|
||||||
})
|
})
|
||||||
const terminatingSessions = ref({})
|
const terminatingSessions = ref({})
|
||||||
|
|
||||||
const terminateSession = async (session) => {
|
const terminateSession = async (session) => {
|
||||||
const sessionId = session?.id
|
const sessionKey = session?.key
|
||||||
if (!sessionId) return
|
if (!sessionKey) return
|
||||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
|
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
|
||||||
try { await authStore.terminateSession(sessionId) }
|
try { await authStore.terminateSession(sessionKey) }
|
||||||
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
|
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
|
||||||
finally {
|
finally {
|
||||||
const next = { ...terminatingSessions.value }
|
const next = { ...terminatingSessions.value }
|
||||||
delete next[sessionId]
|
delete next[sessionKey]
|
||||||
terminatingSessions.value = next
|
terminatingSessions.value = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
|
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
|
||||||
const logout = async () => { await authStore.logout() }
|
const logout = async () => { await authStore.logout() }
|
||||||
const openNameDialog = () => { newName.value = authStore.userInfo?.user?.user_name || ''; showNameDialog.value = true }
|
const openEditDialog = () => { showEditDialog.value = true }
|
||||||
const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin))
|
const isAdmin = computed(() => {
|
||||||
const hasMultipleSessions = computed(() => sessions.value.length > 1)
|
const perms = Object.values(authStore.userInfo.permissions).map(p => p.scope)
|
||||||
const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
|
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
|
||||||
|
})
|
||||||
|
const hasMultipleSessions = computed(() => Object.keys(sessions.value).length > 1)
|
||||||
|
const credentials = computed(() =>
|
||||||
|
Object.entries(authStore.userInfo.credentials).map(([uuid, c]) => ({ ...c, credential: uuid }))
|
||||||
|
)
|
||||||
|
const useWideLayout = computed(() => {
|
||||||
|
// Check if any single site has more than 8 sessions
|
||||||
|
const groups = {}
|
||||||
|
for (const session of Object.values(sessions.value)) {
|
||||||
|
const host = session.host || ''
|
||||||
|
if (!groups[host]) groups[host] = []
|
||||||
|
groups[host].push(session)
|
||||||
|
}
|
||||||
|
const hasLargeSessionGroup = Object.values(groups).some(group => group.length > 8)
|
||||||
|
|
||||||
const saveName = async () => {
|
// Check if passkeys exceed 8
|
||||||
const name = newName.value.trim()
|
const hasManyCredentials = credentials.value.length > 8
|
||||||
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
|
|
||||||
|
return hasLargeSessionGroup || hasManyCredentials
|
||||||
|
})
|
||||||
|
const breadcrumbEntries = computed(() => { const entries = [{ label: 'My Profile', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
|
||||||
|
|
||||||
|
const saveProfile = async () => {
|
||||||
|
const name = editName.value.trim()
|
||||||
|
if (!name) { editError.value = 'Name cannot be empty'; return }
|
||||||
|
const user = authStore.userInfo.user
|
||||||
|
const emailVal = editEmail.value.trim() || null
|
||||||
|
const usernameVal = editUsername.value.trim() || null
|
||||||
|
const telephoneVal = editTelephone.value.trim() || null
|
||||||
try {
|
try {
|
||||||
|
editError.value = ''
|
||||||
saving.value = true
|
saving.value = true
|
||||||
await apiJson('/auth/api/user/display-name', { method: 'PUT', body: { display_name: name } })
|
let changed = false
|
||||||
showNameDialog.value = false
|
const body = {}
|
||||||
await authStore.loadUserInfo()
|
if (name !== user.display_name) body.display_name = name
|
||||||
authStore.showMessage('Name updated successfully!', 'success', 3000)
|
if (emailVal !== (user.email || null)) body.email = emailVal
|
||||||
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
|
if (usernameVal !== (user.preferred_username || null)) body.preferred_username = usernameVal
|
||||||
finally { saving.value = false }
|
if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
|
||||||
|
if (Object.keys(body).length) {
|
||||||
|
await apiJson('/auth/api/user/info', { method: 'PATCH', body })
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
await authStore.loadUserInfo()
|
||||||
|
authStore.showMessage('Profile updated!', 'success', 3000)
|
||||||
|
}
|
||||||
|
showEditDialog.value = false
|
||||||
|
} catch (e) {
|
||||||
|
editError.value = e.message || 'Failed to update profile'
|
||||||
|
} finally { saving.value = false }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; }
|
|
||||||
.section-header { display: flex; flex-direction: column; gap: 0.4rem; }
|
.section-header { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
.empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; }
|
.empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; }
|
||||||
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
|
.logout-note { margin: 0; color: var(--color-text-muted); }
|
||||||
|
.logout-footer { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin-top: 0.75rem; }
|
||||||
|
.paskia-version { color: var(--color-text-muted); text-decoration: none; white-space: nowrap; font-weight: 700; }
|
||||||
|
.paskia-version:hover { color: var(--color-link-hover); }
|
||||||
.remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; }
|
.remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
.remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
|
.remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
|
||||||
.remote-auth-description {
|
.remote-auth-description { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||||
font-size: 0.75rem;
|
.theme-toggle { position: absolute; top: var(--layout-padding); right: var(--layout-padding); }
|
||||||
color: var(--color-text-muted);
|
.profile-edit-form { display: flex; flex-direction: column; gap: var(--space-md); }
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
|
<div v-if="linkUrl" class="dialog-overlay" @click="$emit('close')">
|
||||||
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
|
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
|
||||||
|
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
|
||||||
<div class="reg-header-row">
|
<div class="reg-header-row">
|
||||||
<h2 id="regTitle" class="reg-title">
|
<h2 id="regTitle" class="reg-title">
|
||||||
📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Add Another Device</span>
|
📱 <span v-if="userName">{{ tokenType === 'account recovery' ? 'Recovery' : 'Registration' }} for {{ userName }}</span><span v-else>Add Another Device</span>
|
||||||
</h2>
|
</h2>
|
||||||
<button class="icon-btn" @click="$emit('close')" aria-label="Close" tabindex="-1">✕</button>
|
<button class="icon-btn" @click="$emit('close')" aria-label="Close" tabindex="-1">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="device-link-section">
|
<div class="device-link-section">
|
||||||
<p class="reg-help">
|
<p class="reg-help">
|
||||||
Scan this QR code on the new device, or copy the link and open it there.
|
{{ helpText }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<QRCodeDisplay
|
<QRCodeDisplay
|
||||||
@@ -28,16 +29,18 @@
|
|||||||
<div class="reg-actions" ref="actionsRow" @keydown="handleActionsKeydown">
|
<div class="reg-actions" ref="actionsRow" @keydown="handleActionsKeydown">
|
||||||
<button class="btn-secondary" @click="$emit('close')">Close</button>
|
<button class="btn-secondary" @click="$emit('close')">Close</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
|
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
|
||||||
import { apiJson } from '@/utils/api'
|
import { apiJson, AuthCancelledError, holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||||
import { formatDate } from '@/utils/helpers'
|
import { formatDate } from '@/utils/helpers'
|
||||||
import { getDirection } from '@/utils/keynav'
|
import { getDirection } from '@/utils/keynav'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
endpoint: { type: String, required: true },
|
endpoint: { type: String, required: true },
|
||||||
@@ -46,34 +49,50 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['close', 'copied'])
|
const emit = defineEmits(['close', 'copied'])
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
const dialog = ref(null)
|
const dialog = ref(null)
|
||||||
const linkUrl = ref(null)
|
const linkUrl = ref(null)
|
||||||
const expiresAt = ref(null)
|
const expiresAt = ref(null)
|
||||||
|
const tokenType = ref(null)
|
||||||
const actionsRow = ref(null)
|
const actionsRow = ref(null)
|
||||||
// Store the element that had focus before modal opened
|
// Store the element that had focus before modal opened
|
||||||
const previouslyFocusedElement = ref(null)
|
const previouslyFocusedElement = ref(null)
|
||||||
|
|
||||||
|
// Determine if this is an admin action for another user
|
||||||
|
const isAdminAction = computed(() => !!props.userName)
|
||||||
|
|
||||||
|
// Compute the help text based on token type and context
|
||||||
|
const helpText = computed(() => {
|
||||||
|
if (!isAdminAction.value) {
|
||||||
|
// User adding their own device
|
||||||
|
return 'Scan this QR code on the new device, or copy the link and open it there.'
|
||||||
|
}
|
||||||
|
// Admin action for another user
|
||||||
|
return `Send this link to ${props.userName}, or have them scan the QR code.`
|
||||||
|
})
|
||||||
|
|
||||||
async function generateLink() {
|
async function generateLink() {
|
||||||
try {
|
try {
|
||||||
const data = await apiJson(props.endpoint, { method: 'POST' })
|
const data = await apiJson(props.endpoint, { method: 'POST' })
|
||||||
if (data.url) {
|
if (data.url) {
|
||||||
linkUrl.value = data.url
|
linkUrl.value = data.url
|
||||||
expiresAt.value = data.expires ? new Date(data.expires) : null
|
expiresAt.value = data.expires ? new Date(data.expires) : null
|
||||||
|
tokenType.value = data.token_type || null
|
||||||
|
|
||||||
// Show the dialog as modal
|
holdGlobalBackdrop()
|
||||||
|
|
||||||
|
// Focus primary button (or first button if no primary) after content renders
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (dialog.value) {
|
const actions = actionsRow.value
|
||||||
dialog.value.showModal()
|
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
|
||||||
|
target?.focus()
|
||||||
// Focus primary button (or first button if no primary) after content renders
|
|
||||||
const actions = actionsRow.value
|
|
||||||
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
|
|
||||||
target?.focus()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
if (!(e instanceof AuthCancelledError)) {
|
||||||
|
authStore.showMessage(e.message || 'Failed to generate link', 'error')
|
||||||
|
}
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +102,12 @@ function onCopied() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDialogKeydown = (event) => {
|
const handleDialogKeydown = (event) => {
|
||||||
// ESC is handled automatically by <dialog>
|
// ESC to close
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('close')
|
||||||
|
return
|
||||||
|
}
|
||||||
// Handle other key navigation
|
// Handle other key navigation
|
||||||
const direction = getDirection(event)
|
const direction = getDirection(event)
|
||||||
if (!direction) return
|
if (!direction) return
|
||||||
@@ -129,6 +153,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
if (linkUrl.value) releaseGlobalBackdrop()
|
||||||
// Restore focus when modal closes
|
// Restore focus when modal closes
|
||||||
const prev = previouslyFocusedElement.value
|
const prev = previouslyFocusedElement.value
|
||||||
if (prev && document.body.contains(prev) && !prev.disabled) {
|
if (prev && document.body.contains(prev) && !prev.disabled) {
|
||||||
@@ -138,23 +163,6 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
dialog {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
max-width: none;
|
|
||||||
width: fit-content;
|
|
||||||
height: fit-content;
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog::backdrop {
|
|
||||||
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
|
|
||||||
backdrop-filter: blur(.2rem) brightness(0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
|
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
|
||||||
.icon-btn:hover { opacity: 1; }
|
.icon-btn:hover { opacity: 1; }
|
||||||
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
|
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
|
||||||
|
|||||||
@@ -53,17 +53,16 @@
|
|||||||
<!-- Device info display (shown when 3 words match a request) -->
|
<!-- Device info display (shown when 3 words match a request) -->
|
||||||
<div v-else-if="deviceInfo" class="device-info">
|
<div v-else-if="deviceInfo" class="device-info">
|
||||||
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
|
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
|
||||||
<p class="device-meta">{{ deviceInfo.user_agent_pretty }}</p>
|
<p class="device-meta">{{ deviceInfo.user_agent_pretty || '—' }}</p>
|
||||||
|
|
||||||
<p v-if="error" class="error-message" style="margin-top: 0.5rem;">{{ error }}</p>
|
<p v-if="error" class="error-message">{{ error }}</p>
|
||||||
|
|
||||||
<div class="button-row" style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
|
<div class="button-row device-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@click="deny"
|
@click="deny"
|
||||||
style="flex: 1;"
|
|
||||||
>
|
>
|
||||||
Deny
|
Deny
|
||||||
</button>
|
</button>
|
||||||
@@ -72,7 +71,6 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
style="flex: 1;"
|
|
||||||
>
|
>
|
||||||
{{ loading ? 'Authenticating…' : 'Authorize' }}
|
{{ loading ? 'Authenticating…' : 'Authorize' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -773,7 +771,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
|
|||||||
.input-wrapper {
|
.input-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 280px;
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,10 +918,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
|
|||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-info {
|
.device-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -945,9 +938,18 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
|
|||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
margin: 0;
|
margin: 0.5rem 0 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--color-error, #ef4444);
|
color: var(--color-error, #ef4444);
|
||||||
margin-bottom: 1rem;
|
}
|
||||||
|
|
||||||
|
.device-actions {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-actions button {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<!-- Error state -->
|
<!-- Error state -->
|
||||||
<div v-else-if="error" class="error-section">
|
<div v-else-if="error" class="error-section">
|
||||||
<p class="error-message">{{ error }}</p>
|
<p class="error-message">{{ error }}</p>
|
||||||
<button class="btn-primary" @click="retry" style="margin-top: 0.75rem;">Try Again</button>
|
<button class="btn-primary" @click="retry">Try Again</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Connecting phase -->
|
<!-- Connecting phase -->
|
||||||
@@ -163,6 +163,9 @@ async function startRemoteAuth() {
|
|||||||
|
|
||||||
// PoW challenge
|
// PoW challenge
|
||||||
const powChallenge = await ws.receive_json()
|
const powChallenge = await ws.receive_json()
|
||||||
|
if (powChallenge.status) {
|
||||||
|
throw new Error(powChallenge.detail || `Failed to connect: ${powChallenge.status}`)
|
||||||
|
}
|
||||||
if (powChallenge.pow) {
|
if (powChallenge.pow) {
|
||||||
const challenge = b64dec(powChallenge.pow.challenge)
|
const challenge = b64dec(powChallenge.pow.challenge)
|
||||||
const nonces = await solvePoW(challenge, powChallenge.pow.work)
|
const nonces = await solvePoW(challenge, powChallenge.pow.work)
|
||||||
@@ -196,7 +199,7 @@ async function startRemoteAuth() {
|
|||||||
} else if (msg.status === 'authenticated') {
|
} else if (msg.status === 'authenticated') {
|
||||||
// Success
|
// Success
|
||||||
completed.value = true
|
completed.value = true
|
||||||
emit('authenticated', { session_token: msg.session_token })
|
emit('authenticated', { exchange_code: msg.exchange_code })
|
||||||
break
|
break
|
||||||
} else if (msg.status === 'denied') {
|
} else if (msg.status === 'denied') {
|
||||||
// Explicitly denied by the authenticating device
|
// Explicitly denied by the authenticating device
|
||||||
@@ -293,10 +296,6 @@ defineExpose({ retry, cancel })
|
|||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-display {
|
.auth-display {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -499,6 +498,10 @@ defineExpose({ retry, cancel })
|
|||||||
color: var(--color-error, #ef4444);
|
color: var(--color-error, #ef4444);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-section button {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.auth-content {
|
.auth-content {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<div v-if="status.show" class="global-status" style="display: block;">
|
<div v-if="status.show" class="global-status show">
|
||||||
<div :class="['status', status.type]">
|
<div :class="['status', status.type]">
|
||||||
{{ status.message }}
|
{{ status.message }}
|
||||||
</div>
|
</div>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<div class="section-body center">
|
<div class="section-body center">
|
||||||
<!-- Local passkey authentication view -->
|
<!-- Local passkey authentication view -->
|
||||||
<div v-if="authView === 'local'" class="auth-view">
|
<div v-if="authView === 'local'" class="auth-view">
|
||||||
<div class="button-row center" ref="buttonRow">
|
<div class="button-row button-row--center" ref="buttonRow">
|
||||||
<slot name="actions"
|
<slot name="actions"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:can-authenticate="canAuthenticate"
|
:can-authenticate="canAuthenticate"
|
||||||
@@ -58,15 +58,20 @@
|
|||||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||||
import passkey from '@/utils/passkey'
|
import passkey from '@/utils/passkey'
|
||||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||||
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
|
import { fetchJson, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
|
||||||
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
|
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
|
||||||
import { focusDialogButton } from '@/utils/keynav'
|
import { focusDialogButton } from '@/utils/keynav'
|
||||||
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
mode: {
|
mode: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'login',
|
default: 'login',
|
||||||
validator: (value) => ['login', 'reauth', 'forbidden'].includes(value)
|
validator: (value) => ['login', 'reauth', 'forbidden', 'oidc'].includes(value)
|
||||||
|
},
|
||||||
|
oidcQueryString: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -76,13 +81,13 @@ const status = reactive({ show: false, message: '', type: 'info' })
|
|||||||
const initializing = ref(true)
|
const initializing = ref(true)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const settings = ref(null)
|
const settings = ref(null)
|
||||||
const userInfo = ref(null)
|
const session = ref(null)
|
||||||
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
|
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
|
||||||
const authView = ref('local') // 'local' or 'remote'
|
const authView = ref('local') // 'local' or 'remote'
|
||||||
const buttonRow = ref(null)
|
const buttonRow = ref(null)
|
||||||
let statusTimer = null
|
let statusTimer = null
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
|
const isAuthenticated = computed(() => !!session.value)
|
||||||
|
|
||||||
const canAuthenticate = computed(() => {
|
const canAuthenticate = computed(() => {
|
||||||
if (initializing.value) return false
|
if (initializing.value) return false
|
||||||
@@ -115,7 +120,7 @@ const headerMessage = computed(() => {
|
|||||||
return 'Please sign in with your passkey.'
|
return 'Please sign in with your passkey.'
|
||||||
})
|
})
|
||||||
|
|
||||||
const userDisplayName = computed(() => userInfo.value?.user?.user_name || 'User')
|
const userDisplayName = computed(() => session.value?.ctx.user.display_name || 'User')
|
||||||
|
|
||||||
function showMessage(message, type = 'info', duration = 3000) {
|
function showMessage(message, type = 'info', duration = 3000) {
|
||||||
status.show = true
|
status.show = true
|
||||||
@@ -140,22 +145,22 @@ async function fetchSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUserInfo() {
|
async function validateSession() {
|
||||||
try {
|
try {
|
||||||
userInfo.value = await fetchJson('/auth/api/user-info', { method: 'POST' })
|
session.value = await fetchJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
||||||
|
updateThemeFromSession(session.value?.ctx)
|
||||||
if (isAuthenticated.value && props.mode !== 'reauth') {
|
if (isAuthenticated.value && props.mode !== 'reauth') {
|
||||||
currentView.value = 'forbidden'
|
currentView.value = 'forbidden'
|
||||||
emit('forbidden', userInfo.value)
|
emit('forbidden', session.value)
|
||||||
} else {
|
} else {
|
||||||
currentView.value = 'login'
|
currentView.value = 'login'
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load user info', error)
|
session.value = null
|
||||||
|
currentView.value = 'login'
|
||||||
if (error.status !== 401 && error.status !== 403) {
|
if (error.status !== 401 && error.status !== 403) {
|
||||||
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
|
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
|
||||||
}
|
}
|
||||||
userInfo.value = null
|
|
||||||
currentView.value = 'login'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +169,7 @@ async function authenticateUser() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
showMessage('Starting authentication…', 'info')
|
showMessage('Starting authentication…', 'info')
|
||||||
let result
|
let result
|
||||||
try { result = await passkey.authenticate() } catch (error) {
|
try { result = await passkey.authenticate(props.oidcQueryString) } catch (error) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
const message = error?.message || 'Passkey authentication cancelled'
|
const message = error?.message || 'Passkey authentication cancelled'
|
||||||
const cancelled = message === 'Passkey authentication cancelled'
|
const cancelled = message === 'Passkey authentication cancelled'
|
||||||
@@ -172,7 +177,13 @@ async function authenticateUser() {
|
|||||||
emit('auth-error', { message, cancelled })
|
emit('auth-error', { message, cancelled })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try { await setSessionCookie(result) } catch (error) {
|
// OIDC flow: no session cookie, just emit the redirect_url
|
||||||
|
if (result.redirect_url) {
|
||||||
|
loading.value = false
|
||||||
|
emit('authenticated', result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try { await exchangeCode(result) } catch (error) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
const message = error?.message || 'Failed to establish session'
|
const message = error?.message || 'Failed to establish session'
|
||||||
showMessage(message, 'error', 4000)
|
showMessage(message, 'error', 4000)
|
||||||
@@ -187,8 +198,8 @@ async function logoutUser() {
|
|||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await fetchJson('/auth/api/logout', { method: 'POST' })
|
await fetchJson('/auth/api/logout', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
||||||
userInfo.value = null
|
session.value = null
|
||||||
currentView.value = 'login'
|
currentView.value = 'login'
|
||||||
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
|
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -203,13 +214,13 @@ function openProfile() {
|
|||||||
if (profileWindow) profileWindow.focus()
|
if (profileWindow) profileWindow.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setSessionCookie(result) {
|
async function exchangeCode(result) {
|
||||||
if (!result?.session_token) {
|
if (!result?.exchange_code) {
|
||||||
console.error('setSessionCookie called with missing session_token:', result)
|
console.error('exchangeCode called with missing exchange_code:', result)
|
||||||
throw new Error('Authentication response missing session_token')
|
throw new Error('Authentication response missing exchange_code')
|
||||||
}
|
}
|
||||||
return await fetchJson('/auth/api/set-session', {
|
return await fetchJson('/auth/api/set-session', {
|
||||||
method: 'POST', headers: { Authorization: `Bearer ${result.session_token}` }
|
method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }, timeout: paskiaSettings.auth_ms
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +235,7 @@ function switchToLocal() {
|
|||||||
async function handleRemoteAuthenticated(result) {
|
async function handleRemoteAuthenticated(result) {
|
||||||
showMessage('Authenticated from another device!', 'success', 2000)
|
showMessage('Authenticated from another device!', 'success', 2000)
|
||||||
try {
|
try {
|
||||||
await setSessionCookie(result)
|
await exchangeCode(result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error?.message || 'Failed to establish session'
|
const message = error?.message || 'Failed to establish session'
|
||||||
showMessage(message, 'error', 4000)
|
showMessage(message, 'error', 4000)
|
||||||
@@ -266,7 +277,12 @@ watch(initializing, (newVal) => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchSettings()
|
await fetchSettings()
|
||||||
await fetchUserInfo()
|
// OIDC mode doesn't depend on session state - skip validation
|
||||||
|
if (props.mode !== 'oidc') {
|
||||||
|
await validateSession()
|
||||||
|
} else {
|
||||||
|
currentView.value = 'login'
|
||||||
|
}
|
||||||
initializing.value = false
|
initializing.value = false
|
||||||
|
|
||||||
// Add click handler for inline links
|
// Add click handler for inline links
|
||||||
@@ -280,12 +296,11 @@ onUnmounted(() => {
|
|||||||
defineExpose({
|
defineExpose({
|
||||||
showMessage,
|
showMessage,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
userInfo
|
session
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.button-row.center { display: flex; justify-content: center; gap: 0.75rem; flex-wrap: wrap; }
|
|
||||||
.user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); }
|
.user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); }
|
||||||
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||||
.surface.surface--tight {
|
.surface.surface--tight {
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="section-block" data-component="session-list-section">
|
<section :class="['section-block', sectionClass]" data-component="session-list-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Active Sessions</h2>
|
<h2>Active Sessions</h2>
|
||||||
<p class="section-description">{{ sectionDescription }}</p>
|
<p class="section-description">{{ sectionDescription }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-body">
|
<div class="section-body">
|
||||||
<div>
|
<div>
|
||||||
<template v-if="Array.isArray(sessions) && sessions.length">
|
<template v-if="sessionsArray.length">
|
||||||
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, host)">
|
<div v-for="(group, key) in groupedSessions" :key="key" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, key)">
|
||||||
<span :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
|
<span :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
|
||||||
<span class="session-group-icon">🌐</span>
|
<span class="session-group-icon">{{ group.isOIDC ? '🪪' : '🌐' }}</span>
|
||||||
<a v-if="host" :href="hostUrl(host)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ host }}</a>
|
<template v-if="group.isOIDC">{{ group.displayName }}</template>
|
||||||
|
<a v-else-if="key" :href="hostUrl(key)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ key }}</a>
|
||||||
<template v-else>Unbound host</template>
|
<template v-else>Unbound host</template>
|
||||||
</span>
|
</span>
|
||||||
<div class="session-list">
|
<div class="session-list">
|
||||||
<div
|
<div
|
||||||
v-for="session in group.sessions"
|
v-for="session in group.sessions"
|
||||||
:key="session.id"
|
:key="session.key"
|
||||||
:class="['session-item', {
|
:class="['session-item', {
|
||||||
'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
|
'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
|
||||||
'is-hovered': hoveredSession?.id === session.id,
|
'is-hovered': hoveredSession?.key === session.key,
|
||||||
'is-linked-credential': hoveredCredentialUuid === session.credential_uuid
|
'is-linked-credential': hoveredCredentialUuid === session.credential
|
||||||
}]"
|
}]"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@mousedown.prevent
|
@mousedown.prevent
|
||||||
@@ -30,17 +31,17 @@
|
|||||||
@keydown="handleItemKeydown($event, session)"
|
@keydown="handleItemKeydown($event, session)"
|
||||||
>
|
>
|
||||||
<div class="item-top">
|
<div class="item-top">
|
||||||
<h4 class="item-title">{{ session.user_agent }}</h4>
|
<h4 class="item-title">{{ session.user_agent || '—' }}</h4>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
|
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
|
||||||
<span v-else-if="hoveredSession?.id === session.id" class="badge badge-current">Selected</span>
|
<span v-else-if="hoveredSession?.key === session.key" class="badge badge-current">Selected</span>
|
||||||
<span v-else-if="hoveredCredentialUuid === session.credential_uuid" class="badge badge-current">Linked</span>
|
<span v-else-if="hoveredCredentialUuid === session.credential" class="badge badge-current">Linked</span>
|
||||||
<span v-else-if="!hoveredCredentialUuid && isSameHost(session.ip)" class="badge">Same IP</span>
|
<span v-else-if="!hoveredCredentialUuid && isSameHost(session.ip)" class="badge">Same IP</span>
|
||||||
<button
|
<button
|
||||||
@click="$emit('terminate', session)"
|
@click="$emit('terminate', session)"
|
||||||
class="btn-card-delete"
|
class="btn-card-delete"
|
||||||
:disabled="isTerminating(session.id)"
|
:disabled="isTerminating(session.key)"
|
||||||
:title="isTerminating(session.id) ? 'Terminating...' : 'Terminate session'"
|
:title="isTerminating(session.key) ? 'Terminating...' : 'Terminate session'"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>❌</button>
|
>❌</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,12 +70,13 @@ import { hostIP } from '@/utils/helpers'
|
|||||||
import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav'
|
import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
sessions: { type: Array, default: () => [] },
|
sessions: { type: Object, default: () => ({}) },
|
||||||
emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
|
emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
|
||||||
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
|
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
|
||||||
terminatingSessions: { type: Object, default: () => ({}) },
|
terminatingSessions: { type: Object, default: () => ({}) },
|
||||||
hoveredCredentialUuid: { type: String, default: null },
|
hoveredCredentialUuid: { type: String, default: null },
|
||||||
navigationDisabled: { type: Boolean, default: false },
|
navigationDisabled: { type: Boolean, default: false },
|
||||||
|
sectionClass: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['terminate', 'sessionHover', 'navigate-out'])
|
const emit = defineEmits(['terminate', 'sessionHover', 'navigate-out'])
|
||||||
@@ -106,7 +108,7 @@ const handleCardClick = (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId]
|
const isTerminating = (sessionKey) => !!props.terminatingSessions[sessionKey]
|
||||||
|
|
||||||
const handleGroupKeydown = (event, host) => {
|
const handleGroupKeydown = (event, host) => {
|
||||||
const group = event.currentTarget
|
const group = event.currentTarget
|
||||||
@@ -149,7 +151,7 @@ const handleGroupKeydown = (event, host) => {
|
|||||||
const handleItemKeydown = (event, session) => {
|
const handleItemKeydown = (event, session) => {
|
||||||
// Handle delete (always allowed even with modal)
|
// Handle delete (always allowed even with modal)
|
||||||
handleDeleteKey(event, () => {
|
handleDeleteKey(event, () => {
|
||||||
if (!isTerminating(session.id)) emit('terminate', session)
|
if (!isTerminating(session.key)) emit('terminate', session)
|
||||||
})
|
})
|
||||||
if (event.defaultPrevented) return
|
if (event.defaultPrevented) return
|
||||||
|
|
||||||
@@ -208,9 +210,14 @@ const copyIp = async (ip) => {
|
|||||||
|
|
||||||
const displayIp = ip => hostIP(ip) ?? ip
|
const displayIp = ip => hostIP(ip) ?? ip
|
||||||
|
|
||||||
|
// Convert sessions dict to array with key attached
|
||||||
|
const sessionsArray = computed(() =>
|
||||||
|
Object.entries(props.sessions || {}).map(([key, session]) => ({ ...session, key }))
|
||||||
|
)
|
||||||
|
|
||||||
const currentHostIP = computed(() => {
|
const currentHostIP = computed(() => {
|
||||||
if (hoveredIp.value) return hostIP(hoveredIp.value)
|
if (hoveredIp.value) return hostIP(hoveredIp.value)
|
||||||
const current = props.sessions.find(s => s.is_current)
|
const current = sessionsArray.value.find(s => s.is_current)
|
||||||
return current ? hostIP(current.ip) : null
|
return current ? hostIP(current.ip) : null
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -218,27 +225,20 @@ const isSameHost = ip => currentHostIP.value && hostIP(ip) === currentHostIP.val
|
|||||||
|
|
||||||
const groupedSessions = computed(() => {
|
const groupedSessions = computed(() => {
|
||||||
const groups = {}
|
const groups = {}
|
||||||
for (const session of props.sessions) {
|
for (const session of sessionsArray.value) {
|
||||||
const host = session.host || ''
|
const groupKey = session.client || session.host || ''
|
||||||
if (!groups[host]) {
|
if (!groups[groupKey]) {
|
||||||
groups[host] = { sessions: [], isCurrentSite: false }
|
groups[groupKey] = { sessions: [], isCurrentSite: false, isOIDC: !!session.client, displayName: session.client_name || groupKey }
|
||||||
}
|
|
||||||
groups[host].sessions.push(session)
|
|
||||||
if (session.is_current_host) {
|
|
||||||
groups[host].isCurrentSite = true
|
|
||||||
}
|
}
|
||||||
|
groups[groupKey].sessions.push(session)
|
||||||
|
if (session.is_current_host) groups[groupKey].isCurrentSite = true
|
||||||
}
|
}
|
||||||
// Sort sessions within each group by last_renewed descending
|
for (const groupKey in groups) groups[groupKey].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
|
||||||
for (const host in groups) {
|
|
||||||
groups[host].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
|
|
||||||
}
|
|
||||||
// Sort groups by host name (natural sort)
|
|
||||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
||||||
const sortedHosts = Object.keys(groups).sort(collator.compare)
|
const sorted = Object.entries(groups).sort(([, a], [, b]) => {
|
||||||
const sortedGroups = {}
|
if (a.isOIDC !== b.isOIDC) return a.isOIDC ? 1 : -1
|
||||||
for (const host of sortedHosts) {
|
return collator.compare(a.displayName, b.displayName) || collator.compare(a.sessions[0]?.client || '', b.sessions[0]?.client || '')
|
||||||
sortedGroups[host] = groups[host]
|
})
|
||||||
}
|
return Object.fromEntries(sorted)
|
||||||
return sortedGroups
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="authStore.status.show" class="global-status" style="display: block;">
|
<div v-if="authStore.status.show" class="global-status show">
|
||||||
<div :class="['status', authStore.status.type]">
|
<div :class="['status', authStore.status.type]">
|
||||||
{{ authStore.status.message }}
|
{{ authStore.status.message }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<div class="theme-selector" @click.stop>
|
||||||
|
<button v-for="t in themes" :key="t.value" class="theme-icon" :class="{ hidden: isHidden(t.value) }"
|
||||||
|
:style="{ top: getPos(t.value).y + 'px', left: getPos(t.value).x + 'px' }" :title="t.title"
|
||||||
|
@click="handleClick(t.value)">{{ t.icon }}</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { apiJson } from 'paskia'
|
||||||
|
import { updateThemeFromSession, getCachedTheme } from '@/utils/theme'
|
||||||
|
|
||||||
|
const open = ref(false), closing = ref(false), closingValue = ref(null), selected = ref(getCachedTheme())
|
||||||
|
const themes = [{ value: '', icon: '🌓', title: 'Auto' }, { value: 'light', icon: '☀️', title: 'Light' }, { value: 'dark', icon: '🌙', title: 'Dark' }]
|
||||||
|
const center = { x: 16, y: 16 }
|
||||||
|
const expanded = { '': { x: 16, y: 0 }, light: { x: 0, y: 28 }, dark: { x: 32, y: 28 } }
|
||||||
|
|
||||||
|
const getPos = v => closing.value ? (v === closingValue.value ? center : expanded[v]) : open.value ? expanded[v] : (v === selected.value ? center : expanded[v])
|
||||||
|
const isHidden = v => closing.value ? v !== closingValue.value : !open.value && v !== selected.value
|
||||||
|
|
||||||
|
function close(v) {
|
||||||
|
closingValue.value = v
|
||||||
|
closing.value = true
|
||||||
|
setTimeout(() => { open.value = closing.value = false; closingValue.value = null }, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(v) {
|
||||||
|
if (!open.value) { open.value = true; return }
|
||||||
|
close(v)
|
||||||
|
setTimeout(() => {
|
||||||
|
selected.value = v
|
||||||
|
updateThemeFromSession({ user: { theme: v } }, true)
|
||||||
|
apiJson('/auth/api/user/theme', { method: 'PATCH', body: { theme: v } }).catch(() => {})
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOutside(e) { if (open.value && !closing.value && !e.target.closest('.theme-selector')) close(selected.value) }
|
||||||
|
onMounted(() => document.addEventListener('click', onOutside))
|
||||||
|
onUnmounted(() => document.removeEventListener('click', onOutside))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.theme-selector { position: relative; width: 2rem; height: 2rem; }
|
||||||
|
.theme-icon { position: absolute; transform: translate(-50%, -50%); background: none; border: none; font-size: 1.25rem; cursor: pointer; padding: 0.25rem; transition: top 0.2s, left 0.2s, opacity 0.15s; }
|
||||||
|
.theme-icon:hover, .theme-icon:focus-visible { transform: translate(-50%, -50%) scale(1.15); }
|
||||||
|
.theme-icon.hidden { opacity: 0; pointer-events: none; }
|
||||||
|
</style>
|
||||||
@@ -1,23 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
|
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
|
||||||
<h3 class="user-name-heading">
|
<div class="user-info-content">
|
||||||
<span class="icon">👤</span>
|
<ProfilePicture
|
||||||
<span class="user-name-row">
|
:src="avatarUrl"
|
||||||
<span class="display-name" :title="name">{{ name }}</span>
|
:render-version="avatarRenderVersion"
|
||||||
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('editName')" title="Edit name">✏️</button>
|
:clickable="avatarClickable"
|
||||||
</span>
|
:loading="loading"
|
||||||
</h3>
|
:title="avatarClickable ? 'Change profile picture' : ''"
|
||||||
<div v-if="orgDisplayName || roleName" class="org-role-sub">
|
width="5.25rem"
|
||||||
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
|
height="5.25rem"
|
||||||
<div class="role-line" v-if="roleName">{{ roleName }}</div>
|
radius="var(--radius-sm)"
|
||||||
</div>
|
fallback-size="2.8em"
|
||||||
<div class="user-details">
|
class="user-picture"
|
||||||
<span class="date-label"><strong>Visits:</strong></span>
|
:class="avatarClickable ? 'user-picture-btn' : ''"
|
||||||
<span class="date-value">{{ visits || 0 }}</span>
|
@click="emit('avatar-click')"
|
||||||
<span class="date-label"><strong>Registered:</strong></span>
|
/>
|
||||||
<span class="date-value">{{ formatDate(createdAt) }}</span>
|
<h3 class="user-name-heading">
|
||||||
<span class="date-label"><strong>Last seen:</strong></span>
|
<span class="user-name-row">
|
||||||
<span class="date-value">{{ formatDate(lastSeen) }}</span>
|
<span class="display-name" :title="name">{{ name }}</span>
|
||||||
|
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('edit')" title="Edit profile">✏️</button>
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div v-if="orgDisplayName || roleName" class="org-role-sub">
|
||||||
|
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
|
||||||
|
<div class="role-line" v-if="roleName">{{ roleName }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-fields-block">
|
||||||
|
<div v-if="preferred_username" class="contact-item">🆔 {{ preferred_username }}</div>
|
||||||
|
<a v-if="email" :href="`mailto:${email}`" class="contact-link">✉️ {{ email }}</a>
|
||||||
|
<a v-if="telephone" :href="`tel:${telephone}`" class="contact-link">📞 {{ telephone }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="info-line">
|
||||||
|
<span v-if="visits">
|
||||||
|
<span class="info-date">{{ formatDate(createdAt) }}</span>
|
||||||
|
<span class="info-punct"> – </span>
|
||||||
|
<span class="info-date">{{ formatDate(lastSeen) }}</span>
|
||||||
|
<span class="info-punct"> ×</span>
|
||||||
|
<span class="info-count">{{ visits }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
<span class="info-label">Created </span>
|
||||||
|
<span class="info-date">{{ formatDate(createdAt) }}</span>
|
||||||
|
<span class="info-punct"> — Never signed in</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="$slots.default" class="user-info-extra">
|
<div v-if="$slots.default" class="user-info-extra">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
@@ -26,80 +52,85 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||||
import { formatDate } from '@/utils/helpers'
|
import { formatDate } from '@/utils/helpers'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
name: { type: String, required: true },
|
name: { type: String, required: true },
|
||||||
|
avatarUrl: { type: String, default: null },
|
||||||
|
avatarRenderVersion: { type: [Number, String], default: 0 },
|
||||||
|
email: { type: String, default: null },
|
||||||
|
preferred_username: { type: String, default: null },
|
||||||
|
telephone: { type: String, default: null },
|
||||||
visits: { type: [Number, String], default: 0 },
|
visits: { type: [Number, String], default: 0 },
|
||||||
createdAt: { type: [String, Number, Date], default: null },
|
createdAt: { type: [String, Number, Date], default: null },
|
||||||
lastSeen: { type: [String, Number, Date], default: null },
|
lastSeen: { type: [String, Number, Date], default: null },
|
||||||
updateEndpoint: { type: String, default: null },
|
updateEndpoint: { type: String, default: null },
|
||||||
canEdit: { type: Boolean, default: true },
|
canEdit: { type: Boolean, default: true },
|
||||||
|
avatarClickable: { type: Boolean, default: false },
|
||||||
loading: { type: Boolean, default: false },
|
loading: { type: Boolean, default: false },
|
||||||
orgDisplayName: { type: String, default: '' },
|
orgDisplayName: { type: String, default: '' },
|
||||||
roleName: { type: String, default: '' }
|
roleName: { type: String, default: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['saved', 'editName'])
|
const emit = defineEmits(['saved', 'edit', 'avatar-click'])
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
const userLoaded = computed(() => !!props.name)
|
const userLoaded = computed(() => !!props.name)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.user-info.has-extra {
|
.user-info.has-extra {
|
||||||
grid-template-columns: auto 1fr 2fr;
|
grid-template-columns: minmax(0, 1fr) 14rem;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"heading heading extra"
|
"content extra";
|
||||||
"org org extra"
|
gap: 1.5rem;
|
||||||
"label1 value1 extra"
|
|
||||||
"label2 value2 extra"
|
|
||||||
"label3 value3 extra";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info:not(.has-extra) {
|
.user-info:not(.has-extra) {
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"heading heading"
|
"content";
|
||||||
"org org"
|
|
||||||
"label1 value1"
|
|
||||||
"label2 value2"
|
|
||||||
"label3 value3";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.user-info.has-extra {
|
.user-info.has-extra {
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"heading heading"
|
"content"
|
||||||
"org org"
|
"extra";
|
||||||
"label1 value1"
|
|
||||||
"label2 value2"
|
|
||||||
"label3 value3"
|
|
||||||
"extra extra";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
|
.user-info-content {
|
||||||
.org-role-sub { grid-area: org; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
|
grid-area: content;
|
||||||
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
display: grid;
|
||||||
.role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; }
|
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr);
|
||||||
.info-label:nth-of-type(1) { grid-area: label1; }
|
grid-template-areas:
|
||||||
.info-value:nth-of-type(2) { grid-area: value1; }
|
"picture heading fields"
|
||||||
.info-label:nth-of-type(3) { grid-area: label2; }
|
"picture org fields"
|
||||||
.info-value:nth-of-type(4) { grid-area: value2; }
|
"picture info info";
|
||||||
.info-label:nth-of-type(5) { grid-area: label3; }
|
gap: 0 1rem;
|
||||||
.info-value:nth-of-type(6) { grid-area: value3; }
|
min-width: 0;
|
||||||
.user-info-extra { grid-area: extra; padding-left: 2rem; border-left: 1px solid var(--color-border); }
|
}
|
||||||
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; }
|
|
||||||
.user-name-row.editing { flex: 1 1 auto; }
|
:deep(.user-picture) { grid-area: picture; align-self: stretch; }
|
||||||
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
|
||||||
.name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid var(--color-border-strong); border-radius: 6px; background: var(--color-surface); color: var(--color-text); }
|
.org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
|
||||||
.user-name-heading .name-input { width: auto; }
|
.org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
.name-input:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); }
|
.role-line { font-size: .65rem; color: var(--color-text-muted); line-height: 1.1; }
|
||||||
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; }
|
.info-fields-block { grid-area: fields; display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
|
||||||
|
.contact-item { display: block; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.contact-link { color: var(--color-text); text-decoration: none; display: block; transition: transform 0.1s ease; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.contact-link:hover { transform: scale(1.01); }
|
||||||
|
.info-line { grid-area: info; line-height: 1.4; font-size: 0.9em; }
|
||||||
|
.info-date { color: var(--color-text) !important; }
|
||||||
|
.info-label { color: var(--color-text) !important; }
|
||||||
|
.info-punct { color: var(--color-text-muted) !important; }
|
||||||
|
.info-count { color: var(--color-text-muted) !important; }
|
||||||
|
.user-info-extra { grid-area: extra; padding-left: 1rem; border-left: 1px solid var(--color-border); flex-shrink: 0; }
|
||||||
|
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; min-width: 0; }
|
||||||
|
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||||
|
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; background: transparent; }
|
||||||
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
|
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
|
||||||
.mini-btn:active:not(:disabled) { transform: translateY(1px); }
|
.mini-btn:active:not(:disabled) { transform: translateY(1px); }
|
||||||
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { register, authenticate } from '@/utils/passkey'
|
import { register, authenticate } from '@/utils/passkey'
|
||||||
import { getSettings } from '@/utils/settings'
|
import { getSettings } from '@/utils/settings'
|
||||||
import { apiJson } from '@/utils/api'
|
import { apiJson, settings as paskiaSettings } from 'paskia'
|
||||||
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', {
|
export const useAuthStore = defineStore('auth', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
// Auth State
|
// Auth State
|
||||||
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
|
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
|
||||||
|
ctx: null, // Session context from validate
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
@@ -26,16 +28,18 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
setLoading(flag) {
|
setLoading(flag) {
|
||||||
this.isLoading = !!flag
|
this.isLoading = !!flag
|
||||||
},
|
},
|
||||||
showMessage(message, type = 'info', duration = 3000) {
|
showMessage(message, type = 'info', duration = null) {
|
||||||
|
// Default duration: 5 seconds for errors, 3 seconds for others
|
||||||
|
const effectiveDuration = duration ?? (type === 'error' ? 5000 : 3000)
|
||||||
this.status = {
|
this.status = {
|
||||||
message,
|
message,
|
||||||
type,
|
type,
|
||||||
show: true
|
show: true
|
||||||
}
|
}
|
||||||
if (duration > 0) {
|
if (effectiveDuration > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.status.show = false
|
this.status.show = false
|
||||||
}, duration)
|
}, effectiveDuration)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async setSessionCookie(result) {
|
async setSessionCookie(result) {
|
||||||
@@ -46,6 +50,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
return await apiJson('/auth/api/set-session', {
|
return await apiJson('/auth/api/set-session', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Authorization': `Bearer ${result.session_token}`},
|
headers: {'Authorization': `Bearer ${result.session_token}`},
|
||||||
|
timeout: paskiaSettings.auth_ms,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async register() {
|
async register() {
|
||||||
@@ -83,7 +88,8 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
},
|
},
|
||||||
async loadUserInfo() {
|
async loadUserInfo() {
|
||||||
try {
|
try {
|
||||||
this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
|
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
|
||||||
|
updateThemeFromSession(this.userInfo)
|
||||||
console.log('User info loaded:', this.userInfo)
|
console.log('User info loaded:', this.userInfo)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Suppress toast for 401/403 errors - the auth iframe will handle these
|
// Suppress toast for 401/403 errors - the auth iframe will handle these
|
||||||
@@ -99,9 +105,9 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
|
await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
|
||||||
await this.loadUserInfo()
|
await this.loadUserInfo()
|
||||||
},
|
},
|
||||||
async terminateSession(sessionId) {
|
async terminateSession(sessionKey) {
|
||||||
try {
|
try {
|
||||||
const payload = await apiJson(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' })
|
const payload = await apiJson(`/auth/api/user/session/${sessionKey}`, { method: 'DELETE' })
|
||||||
if (payload?.current_session_terminated) {
|
if (payload?.current_session_terminated) {
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
location.reload()
|
location.reload()
|
||||||
@@ -116,7 +122,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
try {
|
try {
|
||||||
await apiJson('/auth/api/logout', {method: 'POST'})
|
await apiJson('/auth/api/logout', {method: 'POST', timeout: paskiaSettings.auth_ms})
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
location.reload()
|
location.reload()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -129,7 +135,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
},
|
},
|
||||||
async logoutEverywhere() {
|
async logoutEverywhere() {
|
||||||
try {
|
try {
|
||||||
await apiJson('/auth/api/user/logout-all', {method: 'POST'})
|
await apiJson('/auth/api/user/logout-all', {method: 'POST', timeout: paskiaSettings.auth_ms})
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
location.reload()
|
location.reload()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,406 +0,0 @@
|
|||||||
/**
|
|
||||||
* API fetch wrapper that handles authentication errors with iframe-based re-authentication.
|
|
||||||
*
|
|
||||||
* When a 401 or 403 response is received with an `auth` object containing `iframe` URL,
|
|
||||||
* this wrapper shows an authentication iframe and retries the original request after
|
|
||||||
* successful authentication.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Default timeout for API requests in milliseconds */
|
|
||||||
const DEFAULT_TIMEOUT_MS = 1000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom error class for API errors with full response context.
|
|
||||||
*/
|
|
||||||
export class ApiError extends Error {
|
|
||||||
constructor(url, response, data) {
|
|
||||||
super(data?.detail || `Request failed: ${response.status}`)
|
|
||||||
this.name = 'ApiError'
|
|
||||||
this.url = url
|
|
||||||
this.status = response.status
|
|
||||||
this.statusText = response.statusText
|
|
||||||
this.data = data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom error class for network/timeout errors.
|
|
||||||
*/
|
|
||||||
export class NetworkError extends Error {
|
|
||||||
constructor(message, originalError = null) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'NetworkError'
|
|
||||||
this.originalError = originalError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error thrown when user cancels authentication.
|
|
||||||
*/
|
|
||||||
export class AuthCancelledError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super('Authentication cancelled')
|
|
||||||
this.name = 'AuthCancelledError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let authIframe = null
|
|
||||||
let authPromise = null
|
|
||||||
let authResolve = null
|
|
||||||
let authReject = null
|
|
||||||
|
|
||||||
// Global backdrop ref-count (works independently of Pinia store)
|
|
||||||
let backdropHolders = 0
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hold global backdrop (increment ref-count).
|
|
||||||
* Multiple callers can hold the backdrop; it only hides when all release.
|
|
||||||
*/
|
|
||||||
export function holdGlobalBackdrop() {
|
|
||||||
backdropHolders++
|
|
||||||
document.body.classList.add('has-backdrop')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Release global backdrop (decrement ref-count).
|
|
||||||
* Backdrop hides only when ref-count reaches zero.
|
|
||||||
*/
|
|
||||||
export function releaseGlobalBackdrop() {
|
|
||||||
backdropHolders = Math.max(0, backdropHolders - 1)
|
|
||||||
if (backdropHolders === 0) {
|
|
||||||
document.body.classList.remove('has-backdrop')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache for auth iframe URL by mode
|
|
||||||
const authIframeUrlCache = {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the auth iframe URL for a given mode.
|
|
||||||
* Fetches from /auth/api/forward which returns URL in the auth.iframe field.
|
|
||||||
* Results are cached per mode.
|
|
||||||
* @param {string} mode - The auth mode ('login', 'reauth', 'forbidden')
|
|
||||||
* @returns {Promise<string>} - The URL for the iframe
|
|
||||||
*/
|
|
||||||
export async function getAuthIframeUrl(mode = 'login') {
|
|
||||||
if (authIframeUrlCache[mode]) {
|
|
||||||
return authIframeUrlCache[mode]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch from forward endpoint - it returns URL in auth.iframe on 401/403
|
|
||||||
const response = await fetch('/auth/api/forward', { credentials: 'include' })
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
|
||||||
const data = await response.json()
|
|
||||||
if (data.auth?.iframe) {
|
|
||||||
// The iframe field now contains a URL with hash fragment
|
|
||||||
// If mode differs, update the hash param
|
|
||||||
let url = data.auth.iframe
|
|
||||||
if (mode !== data.auth.mode) {
|
|
||||||
url = url.replace(/mode=[^&]*/, `mode=${mode}`)
|
|
||||||
}
|
|
||||||
authIframeUrlCache[mode] = url
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error('Unable to fetch auth iframe URL')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an auth iframe is already open (from any source).
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export function isAuthIframeOpen() {
|
|
||||||
return !!document.getElementById('auth-iframe')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the authentication iframe and return a promise that resolves on success.
|
|
||||||
* If an auth iframe is already open (from any source), hooks into its completion.
|
|
||||||
* Uses global backdrop system to avoid flicker between auth and caller's UI.
|
|
||||||
* @param {string} iframeUrl - The URL for the iframe src
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
* @throws {AuthCancelledError} - If authentication is cancelled by user
|
|
||||||
*/
|
|
||||||
export function showAuthIframe(iframeUrl) {
|
|
||||||
// If we already have a promise (from us), return it
|
|
||||||
if (authPromise) return authPromise
|
|
||||||
|
|
||||||
// If there's already an iframe in the DOM (from App.vue or elsewhere),
|
|
||||||
// create a promise that hooks into the message handler
|
|
||||||
if (document.getElementById('auth-iframe')) {
|
|
||||||
authPromise = new Promise((resolve, reject) => {
|
|
||||||
authResolve = resolve
|
|
||||||
authReject = reject
|
|
||||||
})
|
|
||||||
return authPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
authPromise = new Promise((resolve, reject) => {
|
|
||||||
authResolve = resolve
|
|
||||||
authReject = reject
|
|
||||||
})
|
|
||||||
|
|
||||||
// Remove existing iframe if any
|
|
||||||
hideAuthIframe()
|
|
||||||
|
|
||||||
// Hold global backdrop for auth iframe
|
|
||||||
holdGlobalBackdrop()
|
|
||||||
|
|
||||||
// Create new iframe for authentication using src URL
|
|
||||||
authIframe = document.createElement('iframe')
|
|
||||||
authIframe.id = 'auth-iframe'
|
|
||||||
authIframe.title = 'Authentication'
|
|
||||||
authIframe.allow = 'publickey-credentials-get; publickey-credentials-create'
|
|
||||||
authIframe.src = iframeUrl
|
|
||||||
document.body.appendChild(authIframe)
|
|
||||||
|
|
||||||
return authPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideAuthIframe() {
|
|
||||||
if (authIframe) {
|
|
||||||
authIframe.remove()
|
|
||||||
authIframe = null
|
|
||||||
releaseGlobalBackdrop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAuthMessage(event) {
|
|
||||||
const data = event.data
|
|
||||||
if (!data?.type) return
|
|
||||||
|
|
||||||
switch (data.type) {
|
|
||||||
case 'auth-success':
|
|
||||||
hideAuthIframe()
|
|
||||||
if (authResolve) {
|
|
||||||
authResolve()
|
|
||||||
authPromise = null
|
|
||||||
authResolve = null
|
|
||||||
authReject = null
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'auth-back':
|
|
||||||
case 'auth-close-request':
|
|
||||||
hideAuthIframe()
|
|
||||||
if (authReject) {
|
|
||||||
authReject(new AuthCancelledError())
|
|
||||||
authPromise = null
|
|
||||||
authResolve = null
|
|
||||||
authReject = null
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'auth-error':
|
|
||||||
// Keep iframe open for retry, but if cancelled, treat as back
|
|
||||||
if (data.cancelled && authReject) {
|
|
||||||
hideAuthIframe()
|
|
||||||
authReject(new AuthCancelledError())
|
|
||||||
authPromise = null
|
|
||||||
authResolve = null
|
|
||||||
authReject = null
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Install global message listener
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.addEventListener('message', handleAuthMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch wrapper that handles auth errors with iframe-based re-authentication.
|
|
||||||
* Loops until successful or user cancels authentication.
|
|
||||||
*
|
|
||||||
* @param {string|URL} url - The URL to fetch
|
|
||||||
* @param {RequestInit} [options] - Fetch options
|
|
||||||
* @param {number} [options.timeout] - Timeout in ms (default: 10000, use 0 to disable)
|
|
||||||
* @returns {Promise<Response>} - The fetch response
|
|
||||||
* @throws {AuthCancelledError} - If authentication is cancelled by user
|
|
||||||
* @throws {NetworkError} - If network error or timeout occurs
|
|
||||||
*/
|
|
||||||
export async function apiFetch(url, options = {}) {
|
|
||||||
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
|
|
||||||
|
|
||||||
// Ensure credentials are included for cookie-based auth
|
|
||||||
fetchOptions.credentials = fetchOptions.credentials || 'include'
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
let response
|
|
||||||
try {
|
|
||||||
response = await fetch(url, {...fetchOptions, signal: timeout && AbortSignal.timeout(timeout)})
|
|
||||||
} catch (error) {
|
|
||||||
// Handle network errors and timeouts
|
|
||||||
if (error.name === 'TimeoutError') {
|
|
||||||
throw new NetworkError('Request timed out', error)
|
|
||||||
}
|
|
||||||
if (error.name === 'AbortError') {
|
|
||||||
// Re-throw abort errors as-is (user-initiated cancellation)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
|
|
||||||
throw new NetworkError('Unable to connect to server', error)
|
|
||||||
}
|
|
||||||
throw new NetworkError(error.message || 'Network error', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for auth errors (401/403)
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
|
||||||
// Try to parse the response to get the iframe URL
|
|
||||||
let authInfo = null
|
|
||||||
try {
|
|
||||||
const data = await response.clone().json()
|
|
||||||
authInfo = data.auth
|
|
||||||
} catch {
|
|
||||||
// If we can't parse JSON, no iframe available
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authenticate via iframe (only in top-level window)
|
|
||||||
if (authInfo?.iframe && window === window.top) {
|
|
||||||
// Show auth iframe (or wait for existing one) and retry on success
|
|
||||||
// showAuthIframe returns existing promise if iframe is already open
|
|
||||||
await showAuthIframe(authInfo.iframe)
|
|
||||||
continue // Retry the original request
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convenience method for JSON API calls.
|
|
||||||
* Automatically sets Accept and Content-Type headers.
|
|
||||||
* Returns parsed JSON directly if response is ok, throws ApiError otherwise.
|
|
||||||
*
|
|
||||||
* @param {string|URL} url - The URL to fetch
|
|
||||||
* @param {RequestInit} [options] - Fetch options
|
|
||||||
* @returns {Promise<any>} - Parsed JSON response
|
|
||||||
* @throws {ApiError} - If response is not ok
|
|
||||||
* @throws {NetworkError} - If network error or timeout occurs
|
|
||||||
* @throws {AuthCancelledError} - If authentication is cancelled by user
|
|
||||||
*/
|
|
||||||
export async function apiJson(url, options = {}) {
|
|
||||||
const fetchOptions = { ...options }
|
|
||||||
|
|
||||||
// Set default headers, allowing caller overrides
|
|
||||||
fetchOptions.headers = {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
...fetchOptions.headers,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set Content-Type for requests with JSON body
|
|
||||||
if (fetchOptions.body && typeof fetchOptions.body === 'object' && !(fetchOptions.body instanceof FormData)) {
|
|
||||||
fetchOptions.headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...fetchOptions.headers,
|
|
||||||
}
|
|
||||||
fetchOptions.body = JSON.stringify(fetchOptions.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await apiFetch(url, fetchOptions)
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new ApiError(url, response, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple JSON fetch without auto-auth iframe handling.
|
|
||||||
* Use this in contexts where showing an auth iframe would be inappropriate
|
|
||||||
* (e.g., inside the auth iframe itself).
|
|
||||||
*
|
|
||||||
* @param {string|URL} url - The URL to fetch
|
|
||||||
* @param {RequestInit} [options] - Fetch options
|
|
||||||
* @returns {Promise<any>} - Parsed JSON response
|
|
||||||
* @throws {ApiError} - If response is not ok
|
|
||||||
*/
|
|
||||||
export async function fetchJson(url, options = {}) {
|
|
||||||
const fetchOptions = {
|
|
||||||
credentials: 'include',
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
...options.headers,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, fetchOptions)
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new ApiError(url, response, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert an error to a user-friendly message.
|
|
||||||
* @param {Error} error - The error to convert
|
|
||||||
* @returns {string} - User-friendly error message
|
|
||||||
*/
|
|
||||||
export function getUserFriendlyErrorMessage(error) {
|
|
||||||
if (error instanceof NetworkError) {
|
|
||||||
return error.message
|
|
||||||
}
|
|
||||||
if (error instanceof ApiError) {
|
|
||||||
return error.message
|
|
||||||
}
|
|
||||||
if (error.name === 'TimeoutError') {
|
|
||||||
return 'Request timed out'
|
|
||||||
}
|
|
||||||
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
|
|
||||||
return 'Unable to connect to server'
|
|
||||||
}
|
|
||||||
return error.message || 'An error occurred'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an error should show a toast to the user.
|
|
||||||
* @param {Error} error - The error to check
|
|
||||||
* @returns {boolean} - Whether to show a toast
|
|
||||||
*/
|
|
||||||
export function shouldShowErrorToast(error) {
|
|
||||||
// Don't show toast for user cancellations
|
|
||||||
if (error instanceof AuthCancelledError) return false
|
|
||||||
if (error.name === 'AbortError') return false
|
|
||||||
// Don't show toast for 401/403 errors - the auth iframe will handle these
|
|
||||||
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an API caller with error handling (toast + console.error).
|
|
||||||
* Wraps apiJson calls with consistent error handling for apps.
|
|
||||||
*
|
|
||||||
* @param {Function} showMessage - Function to show toast messages: (message, type, duration) => void
|
|
||||||
* @returns {Function} - Wrapped apiJson that handles errors
|
|
||||||
*/
|
|
||||||
export function createApiCaller(showMessage) {
|
|
||||||
/**
|
|
||||||
* @param {string|URL} url - The URL to fetch
|
|
||||||
* @param {RequestInit} [options] - Fetch options
|
|
||||||
* @returns {Promise<any>} - Parsed JSON response, or undefined on error
|
|
||||||
*/
|
|
||||||
return async function apiCall(url, options = {}) {
|
|
||||||
try {
|
|
||||||
return await apiJson(url, options)
|
|
||||||
} catch (error) {
|
|
||||||
if (!shouldShowErrorToast(error)) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
// Log full error details
|
|
||||||
console.error(`API error for ${url}:`, error instanceof ApiError ? { status: error.status, statusText: error.statusText, data: error.data } : error)
|
|
||||||
// Show user-friendly toast
|
|
||||||
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default apiFetch
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
||||||
import aWebSocket from '@/utils/awaitable-websocket'
|
import aWebSocket from '@/utils/awaitable-websocket'
|
||||||
import { getSettings } from '@/utils/settings'
|
import { getSettings } from '@/utils/settings'
|
||||||
import { showAuthIframe } from '@/utils/api'
|
import { showAuthIframe } from 'paskia'
|
||||||
|
|
||||||
// Generic path normalizer: if an auth_host is configured and differs from current
|
// Generic path normalizer: if an auth_host is configured and differs from current
|
||||||
// host, return absolute URL (scheme derived by aWebSocket). Otherwise, keep as-is.
|
// host, return absolute URL (scheme derived by aWebSocket). Otherwise, keep as-is.
|
||||||
@@ -54,8 +54,13 @@ export async function register(resetToken = null, displayName = null, onstartreg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function authenticate() {
|
export async function authenticate(queryString = null) {
|
||||||
const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate'))
|
// Build URL, optionally appending raw query string (e.g. for OIDC params)
|
||||||
|
let url = await makeUrl('/auth/ws/authenticate')
|
||||||
|
if (queryString) {
|
||||||
|
url += queryString.startsWith('?') ? queryString : `?${queryString}`
|
||||||
|
}
|
||||||
|
const ws = await aWebSocket(url)
|
||||||
try {
|
try {
|
||||||
let res = await ws.receive_json()
|
let res = await ws.receive_json()
|
||||||
if (res.status >= 400) throw new Error(res.detail || `Authentication failed: ${res.status}`)
|
if (res.status >= 400) throw new Error(res.detail || `Authentication failed: ${res.status}`)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Theme override utilities - shared across apps
|
||||||
|
// User preference or URL hash can force light/dark mode
|
||||||
|
|
||||||
|
const TRANSITION_ID = 'theme-transition'
|
||||||
|
const STORAGE_KEY = 'paskia-theme'
|
||||||
|
|
||||||
|
/** Apply theme by setting class on documentElement */
|
||||||
|
export function applyTheme(theme, element = document.documentElement, animate = false) {
|
||||||
|
// Add temporary transition for smooth theme change
|
||||||
|
if (animate) {
|
||||||
|
let transitionStyle = document.getElementById(TRANSITION_ID)
|
||||||
|
if (!transitionStyle) {
|
||||||
|
transitionStyle = document.createElement('style')
|
||||||
|
transitionStyle.id = TRANSITION_ID
|
||||||
|
transitionStyle.textContent = '*, *::before, *::after { transition: background-color 0.3s, color 0.3s, border-color 0.3s, box-shadow 0.3s !important; }'
|
||||||
|
document.head.appendChild(transitionStyle)
|
||||||
|
}
|
||||||
|
setTimeout(() => document.getElementById(TRANSITION_ID)?.remove(), 350)
|
||||||
|
}
|
||||||
|
// If no explicit theme, check system preference
|
||||||
|
const isDark = theme === 'dark' || (theme !== 'light' && matchMedia('(prefers-color-scheme:dark)').matches)
|
||||||
|
element.classList.toggle('dark', isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get theme from localStorage cache */
|
||||||
|
export function getCachedTheme() {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache theme in localStorage */
|
||||||
|
export function setCachedTheme(theme) {
|
||||||
|
if (theme) localStorage.setItem(STORAGE_KEY, theme)
|
||||||
|
else localStorage.removeItem(STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initialize theme from user preference (with localStorage cache for fast load) */
|
||||||
|
export function initThemeFromCache() {
|
||||||
|
applyTheme(getCachedTheme())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update theme from session context (call after login/session load) */
|
||||||
|
export function updateThemeFromSession(ctx, animate = false) {
|
||||||
|
const theme = ctx?.user?.theme || ''
|
||||||
|
setCachedTheme(theme)
|
||||||
|
applyTheme(theme, document.documentElement, animate)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* FastAPI-Vue Vite Plugin
|
||||||
|
* auto-upgrade@fastapi-vue-setup -- remove this if you edit the plugin
|
||||||
|
*
|
||||||
|
* Configures Vite for FastAPI backend integration:
|
||||||
|
* - Proxies /api/* requests to the FastAPI backend
|
||||||
|
* - Builds to the Python module's frontend-build directory
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* paths - Array of paths to proxy (default: ["/api"])
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||||
|
const backendUrl = process.env.PASKIA_BACKEND_URL || "http://localhost:4402"
|
||||||
|
|
||||||
|
// Build proxy configuration for each path
|
||||||
|
const proxy = {}
|
||||||
|
for (const path of paths) {
|
||||||
|
proxy[path] = {
|
||||||
|
target: backendUrl,
|
||||||
|
changeOrigin: false,
|
||||||
|
ws: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "vite-plugin-fastapi-paskia",
|
||||||
|
config: () => ({
|
||||||
|
server: { proxy },
|
||||||
|
build: {
|
||||||
|
outDir: "../paskia/frontend-build",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-19
@@ -4,6 +4,7 @@ import { resolve } from 'node:path'
|
|||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import { existsSync, renameSync, mkdirSync } from 'node:fs'
|
import { existsSync, renameSync, mkdirSync } from 'node:fs'
|
||||||
import sirv from 'sirv'
|
import sirv from 'sirv'
|
||||||
|
import fastapiVue from './vite-plugin-fastapi.js'
|
||||||
|
|
||||||
// Auth host mode: when set, clients accessing the auth host get /auth/ at / and /auth/admin/ at /admin/
|
// Auth host mode: when set, clients accessing the auth host get /auth/ at / and /auth/admin/ at /admin/
|
||||||
const authHost = process.env.PASKIA_AUTH_HOST
|
const authHost = process.env.PASKIA_AUTH_HOST
|
||||||
@@ -12,6 +13,15 @@ export default defineConfig(({ command }) => ({
|
|||||||
appType: 'mpa',
|
appType: 'mpa',
|
||||||
publicDir: 'public',
|
publicDir: 'public',
|
||||||
plugins: [
|
plugins: [
|
||||||
|
fastapiVue({ paths: [
|
||||||
|
"/auth/api",
|
||||||
|
"/auth/ws",
|
||||||
|
"/.well-known/openid-configuration",
|
||||||
|
// Passphrase links: /auth/word1.word2.word3.word4.word5
|
||||||
|
"^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$",
|
||||||
|
// Passphrase links: /word1.word2.word3.word4.word5
|
||||||
|
"^/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$",
|
||||||
|
] }),
|
||||||
vue(),
|
vue(),
|
||||||
// Auth host routing: rewrite paths when accessing dedicated auth host
|
// Auth host routing: rewrite paths when accessing dedicated auth host
|
||||||
// Must run before serve-examples to handle / correctly
|
// Must run before serve-examples to handle / correctly
|
||||||
@@ -37,6 +47,18 @@ export default defineConfig(({ command }) => ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'restricted-endpoints-rewrite',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, _res, next) => {
|
||||||
|
// Rewrite /auth/restricted/iframe and /auth/restricted/oidc to /auth/restricted/
|
||||||
|
if (req.url === '/auth/restricted/iframe' || req.url === '/auth/restricted/oidc') {
|
||||||
|
req.url = '/auth/restricted/'
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'serve-examples',
|
name: 'serve-examples',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
@@ -81,7 +103,9 @@ export default defineConfig(({ command }) => ({
|
|||||||
}
|
}
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
base: '/',
|
base: '/',
|
||||||
server: {
|
server: {
|
||||||
@@ -89,24 +113,6 @@ export default defineConfig(({ command }) => ({
|
|||||||
allowedHosts: true,
|
allowedHosts: true,
|
||||||
fs: {
|
fs: {
|
||||||
allow: ['..']
|
allow: ['..']
|
||||||
},
|
|
||||||
proxy: {
|
|
||||||
// Only proxy these two specific backend API paths
|
|
||||||
'/auth/api': {
|
|
||||||
target: 'http://localhost:4402'
|
|
||||||
},
|
|
||||||
'/auth/ws': {
|
|
||||||
target: 'http://localhost:4402',
|
|
||||||
ws: true
|
|
||||||
},
|
|
||||||
// Passphrase links: /auth/word1.word2.word3.word4.word5
|
|
||||||
'^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$': {
|
|
||||||
target: 'http://localhost:4402'
|
|
||||||
},
|
|
||||||
// Passphrase links: /word1.word2.word3.word4.word5
|
|
||||||
'^/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$': {
|
|
||||||
target: 'http://localhost:4402'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# OIDC Provider Implementation
|
||||||
|
|
||||||
|
OpenID Connect 1.0 provider enabling third-party apps to authenticate users via passkey. Also supports native cookie-based authentication.
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
**User** — Added: `email`, `preferred_username`
|
||||||
|
|
||||||
|
**Session** — Added: `client_uuid` (None = native, set = OIDC)
|
||||||
|
- `key: bytes` — hashed DB key, never stored raw
|
||||||
|
- `secret` → `hash_secret("session", secret)` → DB lookup
|
||||||
|
- OIDC `sid` → `base64url.encode(hash_secret("oidc", session.key))`
|
||||||
|
|
||||||
|
**OIDClient** — `uuid, client_secret_hash, name, redirect_uris`
|
||||||
|
|
||||||
|
## Auth Codes (In-Memory Only)
|
||||||
|
|
||||||
|
60-second lifetime, auto-cleaned:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from paskia.authcode import AuthCode, OIDC, codes
|
||||||
|
|
||||||
|
class AuthCode(msgspec.Struct):
|
||||||
|
session_key: str # Session DB key
|
||||||
|
created: datetime
|
||||||
|
oidc: OIDC | None # Only for OIDC mode
|
||||||
|
|
||||||
|
class OIDC(msgspec.Struct):
|
||||||
|
redirect_uri, scope, nonce, code_challenge, code_challenge_method: str
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage: `code = authcode.store(AuthCode(...))` → later `codes.pop(code, None)`
|
||||||
|
|
||||||
|
## Authorization Flows
|
||||||
|
|
||||||
|
### OIDC (Authorization Code)
|
||||||
|
|
||||||
|
1. `GET /auth/restricted/oidc?client_id=UUID&redirect_uri=...&scope=openid&nonce=...&code_challenge=...`
|
||||||
|
2. Frontend → WebSocket: `/auth/ws/authenticate?client_id=...&redirect_uri=...&...`
|
||||||
|
3. Validate client/redirect_uri, authenticate via passkey
|
||||||
|
4. `db.oidc_login()` → `(secret, session_key)`
|
||||||
|
5. Create `AuthCode(session_key, oidc=OIDC(...))` → code
|
||||||
|
6. Return: `{"redirect_url": "{redirect_uri}?code={code}&state={state}"}`
|
||||||
|
7. Client exchanges code at `/auth/oidc/token` with `code_verifier` (PKCE S256)
|
||||||
|
|
||||||
|
**Token:** `access_token, id_token, refresh_token={secret}, expires_in=3600`
|
||||||
|
|
||||||
|
**ID token:** `sub, sid (base64url), name, preferred_username, email, groups`
|
||||||
|
|
||||||
|
### Native (Cookie)
|
||||||
|
|
||||||
|
1. WebSocket: `/auth/ws/authenticate` (no OIDC params)
|
||||||
|
2. Authenticate via passkey
|
||||||
|
3. `db.login()` → secret
|
||||||
|
4. Create `AuthCode(session_key=secret, oidc=None)` → exchange_code
|
||||||
|
5. Return: `{"user": "UUID", "exchange_code": "..."}`
|
||||||
|
6. `POST /auth/api/exchange` with code → sets cookie
|
||||||
|
|
||||||
|
## Refresh & Logout
|
||||||
|
|
||||||
|
**Refresh:** `POST /auth/oidc/token` with `grant_type=refresh_token&refresh_token={secret}&client_id=...&client_secret=...`
|
||||||
|
- Looks up session, validates client match
|
||||||
|
- Extends expiry +24h (sliding window)
|
||||||
|
- Returns new tokens with same `sid`
|
||||||
|
|
||||||
|
**Back-channel logout:** `POST /auth/oidc/backchannel-logout` with `logout_token={jwt}`
|
||||||
|
- Verify signature, extract `sid` or `sub`
|
||||||
|
- Delete matched sessions
|
||||||
|
- Return 200 OK
|
||||||
|
|
||||||
|
Discovery: `backchannel_logout_supported: true`
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /.well-known/openid-configuration` — Discovery
|
||||||
|
- `GET /auth/oidc/keys` — Keys (EdDSA)
|
||||||
|
- `POST /auth/oidc/token` — Exchange/refresh
|
||||||
|
- `GET /auth/oidc/userinfo` — User (bearer token, includes `picture` when `profile` scope is granted and avatar exists)
|
||||||
|
- `POST /auth/oidc/backchannel-logout` — Logout
|
||||||
|
- `POST /auth/api/exchange` — Native auth code → cookie
|
||||||
|
|
||||||
|
## Claims
|
||||||
|
|
||||||
|
- `profile` scope may include `name`, `preferred_username`, and `picture`
|
||||||
|
- `email` scope may include `email`
|
||||||
|
- `groups` is emitted from client-scoped permissions
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
**Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py)
|
||||||
|
|
||||||
|
**Modified:** [paskia/db/structs.py](paskia/db/structs.py), [paskia/db/operations.py](paskia/db/operations.py), [paskia/fastapi/ws.py](paskia/fastapi/ws.py), [paskia/fastapi/api.py](paskia/fastapi/api.py), [paskia/globals.py](paskia/globals.py), [paskia/fastapi/mainapp.py](paskia/fastapi/mainapp.py)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Paskia
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
JavaScript utilities for [Paskia authentication system](https://git.zi.fi/leovasanko/paskia) integration into web apps.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### NPM
|
||||||
|
|
||||||
|
No framework dependencies. Works with any framework (Vue, React, Svelte, etc.) or vanilla JS. Typescript typing included.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install paskia
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { ... } from 'paskia'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plain JavaScript
|
||||||
|
|
||||||
|
Fetch the module directly from a CDN, or [download](https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js) first and host yourself. No Node needed.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script type="module">
|
||||||
|
import { ... } from 'https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js'
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Session Validation
|
||||||
|
|
||||||
|
Refresh session and track its validity with automatic polling. Pauses on lack of user activity to avoid useless traffic and to allow session expiry even when the page is left open but idle. This monitors that the same account stays logged in but doesn't do any permission checks.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { SessionValidator } from 'paskia'
|
||||||
|
|
||||||
|
const validator = new SessionValidator(
|
||||||
|
() => currentUser?.uuid, // getter for current user ID that we track
|
||||||
|
(error) => handleSessionLost(error) // callback when session is lost
|
||||||
|
)
|
||||||
|
|
||||||
|
validator.start() // call at your app startup/login
|
||||||
|
validator.stop() // stop the system (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Fetch Utilities
|
||||||
|
|
||||||
|
Enhanced fetch functions with automatic error handling and authentication retry:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { apiJson, apiFetch } from 'paskia'
|
||||||
|
|
||||||
|
// JSON API calls with automatic auth handling
|
||||||
|
const data = await apiJson('/api/endpoint', { method: 'POST', body: { key: 'value' } })
|
||||||
|
|
||||||
|
// Raw fetch with auth handling
|
||||||
|
const response = await apiFetch('/api/endpoint')
|
||||||
|
```
|
||||||
|
|
||||||
|
When a 401/403 response includes an auth iframe URL, the request automatically pauses, displays the authentication UI, and retries upon success. In case this is not needed, use standard `fetch` or our `fetchJson`.
|
||||||
|
|
||||||
|
The JSON variants set headers automatically, with body and response in JSON.
|
||||||
|
|
||||||
|
### Timeout Settings
|
||||||
|
|
||||||
|
Paskia exports a mutable settings object for defaults used by fetch/auth/session validation timers. Default values shown below.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { settings } from 'paskia'
|
||||||
|
|
||||||
|
// General fetch timeout used by apiFetch/apiJson/fetchJson when no timeout is passed
|
||||||
|
settings.fetch_ms = 10000
|
||||||
|
|
||||||
|
// Fetch timeout used by SessionValidator (/auth/api/validate is fast)
|
||||||
|
settings.auth_ms = 1000
|
||||||
|
|
||||||
|
// SessionValidator polling and idle timers
|
||||||
|
settings.poll_ms = 60000
|
||||||
|
settings.idle_ms = 300000
|
||||||
|
```
|
||||||
|
|
||||||
|
You can still override timeout per request:
|
||||||
|
|
||||||
|
```js
|
||||||
|
await apiJson('/api/upload', { method: 'POST', body: data, timeout: 30000 })
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Overlay
|
||||||
|
|
||||||
|
Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request.
|
||||||
|
|
||||||
|
The backend returns 401/403 responses with the correct URL for proper user feedback. Alternatively you may use `/auth/restricted/iframe#mode=login`, `mode=reauth` or `mode=forbidden` to trigger the UX flow you need.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { showAuthIframe, AuthCancelledError } from 'paskia'
|
||||||
|
|
||||||
|
const response = await fetch('/api/protected')
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.auth?.iframe) {
|
||||||
|
await showAuthIframe(data.auth.iframe) // Raises AuthCancelledError if the user cancels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This resolves after the user authenticates (possibly with another account than previously), and you should usually retry the original API request. Note that successful authentication doesn't guarantee that the user still has rights to what originally failed.
|
||||||
|
|
||||||
|
### Shared Blur Backdrop
|
||||||
|
|
||||||
|
The authentication dialog displays with a blur backdrop (z-index 1099). The auth iframe uses z-index 9999. Your app dialogs should use z-index 1100–9998 to appear above the backdrop but below authentication.
|
||||||
|
|
||||||
|
The backdrop is also reusable/refcounted, so you can keep consistent visuals for your own dialogs:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||||
|
|
||||||
|
holdGlobalBackdrop()
|
||||||
|
try {
|
||||||
|
await your.own.dialog()
|
||||||
|
} finally {
|
||||||
|
releaseGlobalBackdrop()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The backdrop only disappears after all holders have released it.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### AuthCancelledError (apiFetch, apiJson, showAuthIframe)
|
||||||
|
|
||||||
|
If the user clicks Back in the authentication dialog, refusing to authenticate, `AuthCancelledError` is risen (as a response to postMessage from the iframe). The dialog closes as expected and it is up to the app how to continue from there.
|
||||||
|
|
||||||
|
- Do nothing if the app can continue despite the failed operation (no UI notification needed)
|
||||||
|
- Display a simple Access Denied page with suggestion/button to reload the page to try again
|
||||||
|
|
||||||
|
Do not retry automatically.
|
||||||
|
|
||||||
|
### UI feedback
|
||||||
|
|
||||||
|
A set of small utilities are available for determining whether the user needs a notification and to format the error message.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { getUserFriendlyErrorMessage, shouldShowErrorToast } from 'paskia'
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiJson('/api/action')
|
||||||
|
} catch (e) {
|
||||||
|
if (shouldShowErrorToast(e)) {
|
||||||
|
your.message.display(getUserFriendlyErrorMessage(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "paskia",
|
||||||
|
"version": "1.4.0",
|
||||||
|
"description": "Paskia authentication utilities for JavaScript",
|
||||||
|
"author": "Leo Vasanko",
|
||||||
|
"license": "Unlicense",
|
||||||
|
"homepage": "https://git.zi.fi/LeoVasanko/paskia",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/LeoVasanko/paskia",
|
||||||
|
"directory": "paskia-js"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/paskia.js",
|
||||||
|
"types": "./dist/paskia.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/paskia.d.ts",
|
||||||
|
"import": "./dist/paskia.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "~5.8.0",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"vite-plugin-dts": "^4.5.4"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"auth",
|
||||||
|
"authentication",
|
||||||
|
"paskia",
|
||||||
|
"passkey",
|
||||||
|
"webauthn"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { showAuthIframe, AuthCancelledError } from './overlay'
|
||||||
|
import settings from './settings'
|
||||||
|
|
||||||
|
export { AuthCancelledError }
|
||||||
|
|
||||||
|
export interface ApiFetchOptions extends RequestInit {
|
||||||
|
timeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchJsonOptions extends Omit<RequestInit, 'body'> {
|
||||||
|
timeout?: number
|
||||||
|
body?: BodyInit | Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly url: string
|
||||||
|
readonly status: number
|
||||||
|
readonly statusText: string
|
||||||
|
readonly data: unknown
|
||||||
|
|
||||||
|
constructor(url: string, response: Response, data: unknown) {
|
||||||
|
super((data as { detail?: string })?.detail || `Request failed: ${response.status}`)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.url = url
|
||||||
|
this.status = response.status
|
||||||
|
this.statusText = response.statusText
|
||||||
|
this.data = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NetworkError extends Error {
|
||||||
|
readonly originalError: Error | null
|
||||||
|
|
||||||
|
constructor(message: string, originalError: Error | null = null) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'NetworkError'
|
||||||
|
this.originalError = originalError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch(url: string, options: ApiFetchOptions = {}): Promise<Response> {
|
||||||
|
const { timeout = settings.fetch_ms, ...fetchOptions } = options
|
||||||
|
fetchOptions.credentials = fetchOptions.credentials || 'include'
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
let response: Response
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {...fetchOptions, signal: timeout ? AbortSignal.timeout(timeout) : undefined})
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
if (err.name === 'TimeoutError') {
|
||||||
|
throw new NetworkError('Request timed out', err)
|
||||||
|
}
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (err.name === 'TypeError' && err.message === 'Failed to fetch') {
|
||||||
|
throw new NetworkError('Unable to connect to server', err)
|
||||||
|
}
|
||||||
|
throw new NetworkError(err.message || 'Network error', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
let data: { auth?: { iframe?: string } } | null = null
|
||||||
|
try {
|
||||||
|
data = await response.clone().json()
|
||||||
|
} catch {}
|
||||||
|
if (data?.auth?.iframe && window === window.top) {
|
||||||
|
await showAuthIframe(data.auth.iframe)
|
||||||
|
continue // Retry the original request after successful auth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchFn = (url: string, options?: RequestInit) => Promise<Response>
|
||||||
|
|
||||||
|
export async function apiJson<T = unknown>(url: string, options: FetchJsonOptions = {}): Promise<T> {
|
||||||
|
return fetchJson<T>(url, options, apiFetch)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchJson<T = unknown>(url: string, options: FetchJsonOptions = {}, fetchFn: FetchFn = fetch): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: BodyInit | undefined
|
||||||
|
if (options.body && typeof options.body === 'object' && !(options.body instanceof FormData)) {
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
body = JSON.stringify(options.body)
|
||||||
|
} else {
|
||||||
|
body = options.body as BodyInit
|
||||||
|
}
|
||||||
|
|
||||||
|
const opt: RequestInit = { ...options, headers, body }
|
||||||
|
|
||||||
|
const response = await fetchFn(url, opt)
|
||||||
|
const data = await response.json() as T
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(url, response, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserFriendlyErrorMessage(error: Error): string {
|
||||||
|
if (error instanceof NetworkError) return error.message
|
||||||
|
if (error instanceof ApiError) return error.message
|
||||||
|
if (error.name === 'TimeoutError') return 'Request timed out'
|
||||||
|
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
|
||||||
|
return 'Unable to connect to server'
|
||||||
|
}
|
||||||
|
return error.message || 'An error occurred'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowErrorToast(error: Error): boolean {
|
||||||
|
if (error instanceof AuthCancelledError) return false
|
||||||
|
if (error.name === 'AbortError') return false
|
||||||
|
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShowMessageFn = (message: string, type: string, duration: number) => void
|
||||||
|
|
||||||
|
export function createApiCaller(showMessage: ShowMessageFn) {
|
||||||
|
return async function apiCall<T = unknown>(url: string, options: FetchJsonOptions = {}): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await apiJson<T>(url, options)
|
||||||
|
} catch (error) {
|
||||||
|
if (!shouldShowErrorToast(error as Error)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const err = error as Error
|
||||||
|
console.error(`API error for ${url}:`, err instanceof ApiError ? { status: err.status, statusText: err.statusText, data: err.data } : err)
|
||||||
|
showMessage(getUserFriendlyErrorMessage(err), 'error', 4000)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default apiFetch
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export {
|
||||||
|
ApiError,
|
||||||
|
NetworkError,
|
||||||
|
AuthCancelledError,
|
||||||
|
apiFetch,
|
||||||
|
apiJson,
|
||||||
|
fetchJson,
|
||||||
|
getUserFriendlyErrorMessage,
|
||||||
|
shouldShowErrorToast,
|
||||||
|
createApiCaller,
|
||||||
|
} from './fetch'
|
||||||
|
|
||||||
|
export type { ApiFetchOptions, FetchJsonOptions } from './fetch'
|
||||||
|
|
||||||
|
export { default as settings } from './settings'
|
||||||
|
|
||||||
|
export {
|
||||||
|
holdGlobalBackdrop,
|
||||||
|
releaseGlobalBackdrop,
|
||||||
|
isAuthIframeOpen,
|
||||||
|
hideAuthIframe,
|
||||||
|
showAuthIframe,
|
||||||
|
} from './overlay'
|
||||||
|
|
||||||
|
export { SessionValidator } from './validate'
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
const AUTH_IFRAME_ID = 'paskia-iframe'
|
||||||
|
const STYLES_ID = 'paskia-dialog'
|
||||||
|
const STYLES_TEXT = `\
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1099;
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: blur(0) brightness(1);
|
||||||
|
-webkit-backdrop-filter: blur(0) brightness(1);
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.2s ease-out;
|
||||||
|
}
|
||||||
|
body.paskia-backdrop::before {
|
||||||
|
backdrop-filter: blur(.2rem) brightness(0.7);
|
||||||
|
-webkit-backdrop-filter: blur(.2rem) brightness(0.7);
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
body.paskia-backdrop {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
#${AUTH_IFRAME_ID} {
|
||||||
|
border: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 9999;
|
||||||
|
color-scheme: auto;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
let authIframe: HTMLIFrameElement | null = null
|
||||||
|
let authPromise: Promise<void> | null = null
|
||||||
|
let authResolve: (() => void) | null = null
|
||||||
|
let authReject: ((error: Error) => void) | null = null
|
||||||
|
let messageListenerInstalled = false
|
||||||
|
let backdropHolders = 0
|
||||||
|
|
||||||
|
function injectStyles(): void {
|
||||||
|
if (document.getElementById(STYLES_ID)) return
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.id = STYLES_ID
|
||||||
|
style.textContent = STYLES_TEXT
|
||||||
|
document.head.insertBefore(style, document.head.firstChild)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthCancelledError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Authentication cancelled')
|
||||||
|
this.name = 'AuthCancelledError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function holdGlobalBackdrop(): void {
|
||||||
|
injectStyles()
|
||||||
|
backdropHolders++
|
||||||
|
document.body.classList.add('paskia-backdrop')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseGlobalBackdrop(): void {
|
||||||
|
backdropHolders = Math.max(0, backdropHolders - 1)
|
||||||
|
if (backdropHolders === 0) {
|
||||||
|
document.body.classList.remove('paskia-backdrop')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthIframeOpen(): boolean {
|
||||||
|
return !!document.getElementById(AUTH_IFRAME_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideAuthIframe(): void {
|
||||||
|
if (authIframe) {
|
||||||
|
authIframe.remove()
|
||||||
|
authIframe = null
|
||||||
|
releaseGlobalBackdrop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthMessage(event: MessageEvent): void {
|
||||||
|
const data = event.data as { type?: string }
|
||||||
|
if (!data?.type) return
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'auth-success':
|
||||||
|
hideAuthIframe()
|
||||||
|
if (authResolve) {
|
||||||
|
authResolve()
|
||||||
|
authPromise = null
|
||||||
|
authResolve = null
|
||||||
|
authReject = null
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'auth-back':
|
||||||
|
hideAuthIframe()
|
||||||
|
if (authReject) {
|
||||||
|
authReject(new AuthCancelledError())
|
||||||
|
authPromise = null
|
||||||
|
authResolve = null
|
||||||
|
authReject = null
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureMessageListener(): void {
|
||||||
|
if (messageListenerInstalled) return
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('message', handleAuthMessage)
|
||||||
|
messageListenerInstalled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Promise<void> {
|
||||||
|
injectStyles()
|
||||||
|
ensureMessageListener()
|
||||||
|
|
||||||
|
if (authPromise) return authPromise
|
||||||
|
|
||||||
|
if (document.getElementById(AUTH_IFRAME_ID)) {
|
||||||
|
authPromise = new Promise((resolve, reject) => {
|
||||||
|
authResolve = resolve
|
||||||
|
authReject = reject
|
||||||
|
})
|
||||||
|
return authPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
authPromise = new Promise((resolve, reject) => {
|
||||||
|
authResolve = resolve
|
||||||
|
authReject = reject
|
||||||
|
})
|
||||||
|
|
||||||
|
hideAuthIframe()
|
||||||
|
holdGlobalBackdrop()
|
||||||
|
|
||||||
|
authIframe = document.createElement('iframe')
|
||||||
|
authIframe.id = AUTH_IFRAME_ID
|
||||||
|
authIframe.title = title
|
||||||
|
authIframe.src = iframeUrl
|
||||||
|
document.body.appendChild(authIframe)
|
||||||
|
|
||||||
|
return authPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthIframe(iframeUrl: string, title = 'Authentication'): HTMLIFrameElement {
|
||||||
|
injectStyles()
|
||||||
|
const existing = document.getElementById(AUTH_IFRAME_ID)
|
||||||
|
if (existing) existing.remove()
|
||||||
|
|
||||||
|
const iframe = document.createElement('iframe')
|
||||||
|
iframe.id = AUTH_IFRAME_ID
|
||||||
|
iframe.title = title
|
||||||
|
iframe.src = iframeUrl
|
||||||
|
document.body.appendChild(iframe)
|
||||||
|
|
||||||
|
return iframe
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeAuthIframe(): void {
|
||||||
|
const iframe = document.getElementById(AUTH_IFRAME_ID)
|
||||||
|
if (iframe) iframe.remove()
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
fetch_ms: 10000,
|
||||||
|
auth_ms: 1000,
|
||||||
|
poll_ms: 60000,
|
||||||
|
idle_ms: 300000,
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { apiJson } from './fetch'
|
||||||
|
import settings from './settings'
|
||||||
|
|
||||||
|
export class SessionValidator {
|
||||||
|
private userUuidGetter: () => string | undefined
|
||||||
|
private onSessionLost: (error: Error) => void
|
||||||
|
private pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
private idleTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private active = false
|
||||||
|
|
||||||
|
constructor(userUuidGetter: () => string | undefined, onSessionLost: (error: Error) => void) {
|
||||||
|
this.userUuidGetter = userUuidGetter
|
||||||
|
this.onSessionLost = onSessionLost
|
||||||
|
this.resetIdleTimer = this.resetIdleTimer.bind(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetIdleTimer(): void {
|
||||||
|
if (this.idleTimer) clearTimeout(this.idleTimer)
|
||||||
|
if (!this.active) this.startPolling()
|
||||||
|
this.idleTimer = setTimeout(() => this.stopPolling(), settings.idle_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST', timeout: settings.auth_ms })
|
||||||
|
const newUuid = data.ctx?.user?.uuid
|
||||||
|
if (newUuid !== this.userUuidGetter()) {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as Error).name !== 'NetworkError') {
|
||||||
|
this.stopPolling()
|
||||||
|
this.onSessionLost(error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startPolling(): void {
|
||||||
|
if (this.active) return
|
||||||
|
this.active = true
|
||||||
|
this.pollTimer = setInterval(() => this.validate(), settings.poll_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPolling(): void {
|
||||||
|
this.active = false
|
||||||
|
if (this.pollTimer) {
|
||||||
|
clearInterval(this.pollTimer)
|
||||||
|
this.pollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
window.addEventListener('pointermove', this.resetIdleTimer)
|
||||||
|
window.addEventListener('pointerdown', this.resetIdleTimer)
|
||||||
|
this.resetIdleTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
window.removeEventListener('pointermove', this.resetIdleTimer)
|
||||||
|
window.removeEventListener('pointerdown', this.resetIdleTimer)
|
||||||
|
if (this.idleTimer) clearTimeout(this.idleTimer)
|
||||||
|
this.stopPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationDir": "./dist",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"lib": ["ES2020", "DOM"],
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import dts from 'vite-plugin-dts'
|
||||||
|
|
||||||
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [dts({ rollupTypes: true })],
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, 'src/index.ts'),
|
||||||
|
fileName: 'paskia',
|
||||||
|
formats: ['es'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
from fastapi_vue import server
|
||||||
|
from fastapi_vue.hostutil import parse_endpoints
|
||||||
|
from kanta import Kanta
|
||||||
|
|
||||||
|
from paskia._version import __version__
|
||||||
|
from paskia.db.paths import db_file_path
|
||||||
|
from paskia.db.structs import DB, Config
|
||||||
|
from paskia.util import startupbox
|
||||||
|
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
||||||
|
from paskia.util.hostutil import (
|
||||||
|
normalize_auth_host_and_origins,
|
||||||
|
normalize_origin,
|
||||||
|
validate_auth_host,
|
||||||
|
)
|
||||||
|
from paskia.util.runtime import RuntimeConfig
|
||||||
|
|
||||||
|
EPILOG = """\
|
||||||
|
Example:
|
||||||
|
paskia --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def add_common_options(p: argparse.ArgumentParser) -> None:
|
||||||
|
p.add_argument(
|
||||||
|
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
||||||
|
)
|
||||||
|
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
|
||||||
|
p.add_argument(
|
||||||
|
"--origin",
|
||||||
|
action="append",
|
||||||
|
dest="origins",
|
||||||
|
metavar="URL",
|
||||||
|
help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--auth-host",
|
||||||
|
help=("Dedicated authentication site (optionally with scheme/port)"),
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--save",
|
||||||
|
action="store_true",
|
||||||
|
help="Save the CLI options to database for future runs.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_stored_config(db_path: Path, *, rp_id: str) -> Config:
|
||||||
|
"""Load the stored Config from disk using Kanta in read-only mode.
|
||||||
|
|
||||||
|
This must not depend on PASKIA_CONFIG or the global lifecycle Kanta.
|
||||||
|
If the database file does not exist, a default config is returned.
|
||||||
|
"""
|
||||||
|
if not db_path.exists():
|
||||||
|
return Config(rp_id=rp_id)
|
||||||
|
|
||||||
|
kanta = Kanta(
|
||||||
|
str(db_path),
|
||||||
|
DB(config=Config(rp_id=rp_id)),
|
||||||
|
migrations="paskia.db.migrations",
|
||||||
|
)
|
||||||
|
kanta.ctx.rp_id = rp_id
|
||||||
|
|
||||||
|
async def _read() -> Config:
|
||||||
|
await kanta.open(readonly=True)
|
||||||
|
try:
|
||||||
|
return kanta.data.config
|
||||||
|
finally:
|
||||||
|
await kanta.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
return asyncio.run(_read())
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception("Failed to load database")
|
||||||
|
raise SystemExit(f"{e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Configure logging to remove the "ERROR:root:" prefix
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="paskia",
|
||||||
|
description="Paskia authentication server",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=EPILOG,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-l",
|
||||||
|
"--listen",
|
||||||
|
action="append",
|
||||||
|
metavar="LISTEN",
|
||||||
|
help=(
|
||||||
|
"Endpoint to listen on (default: localhost:4401). "
|
||||||
|
"Forms: host:port port :port [ipv6]:port unix:path /path.sock"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
add_common_options(parser)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Load stored config using a local read-only Kanta instance.
|
||||||
|
# This happens before PASKIA_CONFIG is set, so we must not import
|
||||||
|
# modules that initialize the global database lifecycle.
|
||||||
|
db_path = db_file_path(rp_id=args.rp_id, create_root=True)
|
||||||
|
try:
|
||||||
|
config = _load_stored_config(db_path, rp_id=args.rp_id)
|
||||||
|
except SystemExit as e:
|
||||||
|
print(f"🛑 Paskia {__version__} could not load")
|
||||||
|
sys.exit(str(e))
|
||||||
|
|
||||||
|
# Override stored config with CLI args, or clear with empty string
|
||||||
|
if args.rp_name is not None:
|
||||||
|
config.rp_name = args.rp_name or None
|
||||||
|
if args.auth_host is not None:
|
||||||
|
config.auth_host = args.auth_host or None
|
||||||
|
if args.origins is not None:
|
||||||
|
config.origins = None if args.origins == [""] else args.origins
|
||||||
|
if args.listen is not None:
|
||||||
|
config.listen = None if args.listen == [""] else args.listen
|
||||||
|
|
||||||
|
# Process and normalize auth_host and origins
|
||||||
|
try:
|
||||||
|
validate_auth_host(config.auth_host, config.rp_id) if config.auth_host else None
|
||||||
|
except ValueError as e:
|
||||||
|
raise SystemExit(str(e))
|
||||||
|
if config.origins:
|
||||||
|
config.origins = [normalize_origin(o) for o in config.origins]
|
||||||
|
config.auth_host, config.origins = normalize_auth_host_and_origins(
|
||||||
|
config.auth_host, config.origins
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse first endpoint for site_url fallback
|
||||||
|
ep = next(iter(parse_endpoints(config.listen, DEFAULT_PORT)), {})
|
||||||
|
port = ep.get("port")
|
||||||
|
|
||||||
|
# Compute site_url and site_path
|
||||||
|
# Priority: auth_host > origins[0] > PASKIA_VITE_URL > http://localhost:port > https://rp_id
|
||||||
|
site_path = "/auth/"
|
||||||
|
if config.auth_host:
|
||||||
|
site_url, site_path = config.auth_host, "/"
|
||||||
|
elif config.origins:
|
||||||
|
site_url = config.origins[0]
|
||||||
|
elif vite_url := os.environ.get("PASKIA_VITE_URL"):
|
||||||
|
site_url = vite_url.rstrip("/") # Devserver
|
||||||
|
elif config.rp_id == "localhost" and port:
|
||||||
|
site_url = f"http://localhost:{port}" # Backend directly if we can
|
||||||
|
else:
|
||||||
|
site_url = f"https://{config.rp_id}" # Assume external reverse proxy
|
||||||
|
|
||||||
|
# Build runtime configuration for the server
|
||||||
|
runtime = RuntimeConfig(
|
||||||
|
config=config,
|
||||||
|
site_url=site_url,
|
||||||
|
site_path=site_path,
|
||||||
|
save=args.save,
|
||||||
|
)
|
||||||
|
startupbox.print_startup_config(runtime)
|
||||||
|
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(runtime).decode()
|
||||||
|
|
||||||
|
# Run the server (spawns processes in dev mode)
|
||||||
|
dev = {"reload": True, "reload_dirs": ["paskia"]} if DEVMODE else {}
|
||||||
|
server.run(
|
||||||
|
"paskia.fastapi.mainapp:app",
|
||||||
|
listen=config.listen,
|
||||||
|
default_port=DEFAULT_PORT,
|
||||||
|
log_level="warning",
|
||||||
|
access_log=False,
|
||||||
|
**dev,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -10,6 +10,7 @@ This module provides functionality to:
|
|||||||
import json
|
import json
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from importlib.resources import files
|
from importlib.resources import files
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
__ALL__ = ["AAGUID", "filter"]
|
__ALL__ = ["AAGUID", "filter"]
|
||||||
|
|
||||||
@@ -18,15 +19,27 @@ AAGUID_FILE = files("paskia") / "aaguid" / "combined_aaguid.json"
|
|||||||
AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8"))
|
AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def filter(aaguids: Iterable[str]) -> dict[str, dict]:
|
def filter(aaguids: Iterable[UUID]) -> dict[str, dict]:
|
||||||
"""
|
"""
|
||||||
Get AAGUID information only for the provided set of AAGUIDs.
|
Get AAGUID information only for the provided set of AAGUIDs.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
aaguids: Set of AAGUID strings that the user has credentials for
|
aaguids: Iterable of AAGUIDs (UUIDs) that the user has credentials for
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping AAGUID to authenticator information for only
|
Dictionary mapping AAGUID string to authenticator information for only
|
||||||
the AAGUIDs that the user has and that we have data for
|
the AAGUIDs that the user has and that we have data for
|
||||||
"""
|
"""
|
||||||
return {aaguid: AAGUID[aaguid] for aaguid in aaguids if aaguid in AAGUID}
|
result = {}
|
||||||
|
for a in aaguids:
|
||||||
|
s = str(a)
|
||||||
|
if s in AAGUID:
|
||||||
|
info = AAGUID[s].copy()
|
||||||
|
# Rename icon_light to icon
|
||||||
|
if "icon_light" in info:
|
||||||
|
info["icon"] = info.pop("icon_light")
|
||||||
|
# If icons are the same, set dark to None to save space
|
||||||
|
if info.get("icon") == info.get("icon_dark"):
|
||||||
|
info["icon_dark"] = None
|
||||||
|
result[s] = info
|
||||||
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""
|
||||||
|
Authorization code management for OIDC and cookie exchange flows.
|
||||||
|
|
||||||
|
Codes are short-lived (60 seconds) and stored in-memory only.
|
||||||
|
Two separate stores maintain full isolation between OIDC and cookie flows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Auth codes expire after this duration
|
||||||
|
AUTH_CODE_LIFETIME = timedelta(seconds=60)
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCCode(msgspec.Struct):
|
||||||
|
"""An OIDC authorization code pending token exchange.
|
||||||
|
|
||||||
|
PKCE uses S256 only when provided (verified at token exchange).
|
||||||
|
"""
|
||||||
|
|
||||||
|
session_key: str
|
||||||
|
created: datetime
|
||||||
|
redirect_uri: str
|
||||||
|
scope: str
|
||||||
|
nonce: str | None = None
|
||||||
|
code_challenge: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CookieCode(msgspec.Struct):
|
||||||
|
"""A cookie exchange code for setting session cookie after WebSocket auth."""
|
||||||
|
|
||||||
|
session_key: str
|
||||||
|
created: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# Separate stores for each code type
|
||||||
|
oidc_codes: dict[str, OIDCCode] = {}
|
||||||
|
cookie_codes: dict[str, CookieCode] = {}
|
||||||
|
|
||||||
|
# Background cleanup task
|
||||||
|
_cleanup_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def start():
|
||||||
|
"""Start the cleanup background task."""
|
||||||
|
global _cleanup_task
|
||||||
|
if _cleanup_task is None:
|
||||||
|
_cleanup_task = asyncio.create_task(_cleanup_loop())
|
||||||
|
|
||||||
|
|
||||||
|
async def stop():
|
||||||
|
"""Stop the cleanup background task."""
|
||||||
|
global _cleanup_task
|
||||||
|
if _cleanup_task:
|
||||||
|
_cleanup_task.cancel()
|
||||||
|
try:
|
||||||
|
await _cleanup_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
_cleanup_task = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _cleanup_loop():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(30) # Check every 30 seconds
|
||||||
|
_cleanup_expired()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
_logger.exception("Error in auth code cleanup loop")
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_expired():
|
||||||
|
oldest = datetime.now(UTC) - AUTH_CODE_LIFETIME
|
||||||
|
for code, auth_code in list(oidc_codes.items()):
|
||||||
|
if auth_code.created < oldest:
|
||||||
|
del oidc_codes[code]
|
||||||
|
for code, auth_code in list(cookie_codes.items()):
|
||||||
|
if auth_code.created < oldest:
|
||||||
|
del cookie_codes[code]
|
||||||
|
|
||||||
|
|
||||||
|
def store_oidc(code: OIDCCode) -> str:
|
||||||
|
"""Store an OIDC authorization code and return the code string."""
|
||||||
|
token = secrets.token_urlsafe(12)
|
||||||
|
oidc_codes[token] = code
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def consume_oidc(token: str) -> OIDCCode | None:
|
||||||
|
"""Consume an OIDC code, returning it if valid. Atomic removal."""
|
||||||
|
return oidc_codes.pop(token, None)
|
||||||
|
|
||||||
|
|
||||||
|
def store_cookie(code: CookieCode) -> str:
|
||||||
|
"""Store a cookie exchange code and return the code string."""
|
||||||
|
token = secrets.token_urlsafe(12)
|
||||||
|
cookie_codes[token] = code
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def consume_cookie(token: str) -> CookieCode | None:
|
||||||
|
"""Consume a cookie exchange code, returning it if valid. Atomic removal."""
|
||||||
|
return cookie_codes.pop(token, None)
|
||||||
+24
-83
@@ -8,105 +8,46 @@ independent of any web framework:
|
|||||||
- Credential management
|
- Credential management
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from paskia.config import SESSION_LIFETIME
|
from paskia import db
|
||||||
from paskia.db import ResetToken, Session
|
from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
|
||||||
from paskia.globals import db, passkey
|
from paskia.db.structs import ResetToken
|
||||||
from paskia.util import hostutil
|
from paskia.util import hostutil
|
||||||
from paskia.util.tokens import create_token, reset_key, session_key
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from paskia.db import ResetToken
|
||||||
|
|
||||||
EXPIRES = SESSION_LIFETIME
|
EXPIRES = SESSION_LIFETIME
|
||||||
|
|
||||||
|
|
||||||
|
def session_ctx(auth: str, host: str | None = None):
|
||||||
|
"""Get session context with normalized host."""
|
||||||
|
return db.data().session_ctx(auth, hostutil.normalize_host(host))
|
||||||
|
|
||||||
|
|
||||||
def expires() -> datetime:
|
def expires() -> datetime:
|
||||||
return datetime.now(timezone.utc) + EXPIRES
|
return datetime.now(UTC) + EXPIRES
|
||||||
|
|
||||||
|
|
||||||
def reset_expires() -> datetime:
|
def reset_expires() -> datetime:
|
||||||
from .config import RESET_LIFETIME
|
return datetime.now(UTC) + RESET_LIFETIME
|
||||||
|
|
||||||
return datetime.now(timezone.utc) + RESET_LIFETIME
|
|
||||||
|
|
||||||
|
|
||||||
def session_expiry(session: Session) -> datetime:
|
def get_reset(token: str) -> ResetToken:
|
||||||
"""Calculate the expiration timestamp for a session (UTC aware)."""
|
"""Validate a credential reset token."""
|
||||||
# After migration all renewed timestamps are timezone-aware UTC
|
|
||||||
return session.renewed + EXPIRES
|
|
||||||
|
|
||||||
|
record = ResetToken.by_passphrase(token)
|
||||||
async def create_session(
|
if record:
|
||||||
user_uuid: UUID,
|
|
||||||
credential_uuid: UUID,
|
|
||||||
*,
|
|
||||||
host: str,
|
|
||||||
ip: str,
|
|
||||||
user_agent: str,
|
|
||||||
) -> str:
|
|
||||||
"""Create a new session and return a session token."""
|
|
||||||
normalized_host = hostutil.normalize_host(host)
|
|
||||||
if not normalized_host:
|
|
||||||
raise ValueError("Host required for session creation")
|
|
||||||
hostname = normalized_host.split(":")[0] # Domain names only, IPs aren't supported
|
|
||||||
rp_id = passkey.instance.rp_id
|
|
||||||
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
|
|
||||||
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
|
|
||||||
token = create_token()
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
await db.instance.create_session(
|
|
||||||
user_uuid=user_uuid,
|
|
||||||
credential_uuid=credential_uuid,
|
|
||||||
key=session_key(token),
|
|
||||||
host=normalized_host,
|
|
||||||
ip=ip,
|
|
||||||
user_agent=user_agent,
|
|
||||||
renewed=now,
|
|
||||||
)
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
async def get_reset(token: str) -> ResetToken:
|
|
||||||
"""Validate a credential reset token. Returns None if the token is not well formed (i.e. it is another type of token)."""
|
|
||||||
record = await db.instance.get_reset_token(reset_key(token))
|
|
||||||
if record and record.expiry >= datetime.now(timezone.utc):
|
|
||||||
return record
|
return record
|
||||||
raise ValueError("This authentication link is no longer valid.")
|
raise ValueError("This authentication link is no longer valid.")
|
||||||
|
|
||||||
|
|
||||||
async def get_session(token: str, host: str | None = None) -> Session:
|
def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
|
||||||
"""Validate a session token and return session data if valid."""
|
|
||||||
host = hostutil.normalize_host(host)
|
|
||||||
if not host:
|
|
||||||
raise ValueError("Invalid host")
|
|
||||||
session = await db.instance.get_session(session_key(token))
|
|
||||||
if session and session_expiry(session) >= datetime.now(timezone.utc):
|
|
||||||
if session.host is None:
|
|
||||||
# First time binding: store exact host:port (or IPv6 form) now.
|
|
||||||
await db.instance.set_session_host(session.key, host)
|
|
||||||
session.host = host
|
|
||||||
elif session.host != host:
|
|
||||||
raise ValueError("Session host mismatch")
|
|
||||||
return session
|
|
||||||
raise ValueError("Your session has expired. Please sign in again!")
|
|
||||||
|
|
||||||
|
|
||||||
async def refresh_session_token(token: str, *, ip: str, user_agent: str):
|
|
||||||
"""Refresh a session extending its expiry."""
|
|
||||||
session_record = await db.instance.get_session(session_key(token))
|
|
||||||
if not session_record:
|
|
||||||
raise ValueError("Session not found or expired")
|
|
||||||
updated = await db.instance.update_session(
|
|
||||||
session_key(token),
|
|
||||||
ip=ip,
|
|
||||||
user_agent=user_agent,
|
|
||||||
renewed=datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
if not updated:
|
|
||||||
raise ValueError("Session not found or expired")
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
|
|
||||||
"""Delete a specific credential for the current user."""
|
"""Delete a specific credential for the current user."""
|
||||||
s = await get_session(auth, host=host)
|
ctx = session_ctx(auth, host)
|
||||||
await db.instance.delete_credential(credential_uuid, s.user_uuid)
|
if not ctx:
|
||||||
|
raise ValueError("Session expired")
|
||||||
|
db.delete_credential(credential_uuid, ctx.user.uuid)
|
||||||
|
|||||||
+57
-139
@@ -4,107 +4,37 @@ Bootstrap module for passkey authentication system.
|
|||||||
This module handles initial system setup when a new database is created,
|
This module handles initial system setup when a new database is created,
|
||||||
including creating default admin user, organization, permissions, and
|
including creating default admin user, organization, permissions, and
|
||||||
generating a reset link for initial admin setup.
|
generating a reset link for initial admin setup.
|
||||||
|
|
||||||
|
The actual database seeding is performed by the module-level kanta bootstrap
|
||||||
|
callback defined in :mod:`paskia.db.bootstrap` and registered during
|
||||||
|
:func:`paskia.db.lifecycle.init`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import uuid7
|
from paskia import authsession, db
|
||||||
|
from paskia.db.bootstrap import log_reset_link
|
||||||
|
from paskia.db.structs import Config
|
||||||
|
|
||||||
from paskia import authsession, globals
|
logger = logging.getLogger(__name__)
|
||||||
from paskia.db import Org, Permission, Role, User
|
|
||||||
from paskia.util import hostutil, passphrase, tokens
|
|
||||||
|
|
||||||
|
|
||||||
def _init_logger() -> logging.Logger:
|
def _configure_logger() -> None:
|
||||||
logger = logging.getLogger(__name__)
|
if logger.handlers:
|
||||||
if not logger.handlers and not logging.getLogger().handlers:
|
return
|
||||||
h = logging.StreamHandler()
|
handler = logging.StreamHandler()
|
||||||
h.setFormatter(logging.Formatter("%(message)s"))
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
logger.addHandler(h)
|
logger.addHandler(handler)
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
return logger
|
logger.propagate = False
|
||||||
|
|
||||||
|
|
||||||
logger = _init_logger()
|
_configure_logger()
|
||||||
|
|
||||||
# Shared log message template for admin reset links
|
|
||||||
ADMIN_RESET_MESSAGE = """\
|
|
||||||
%s
|
|
||||||
|
|
||||||
👤 Admin %s
|
|
||||||
- Use this link to register a Passkey for the admin user!
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> str:
|
def _log_reset_link(passphrase: str, message: str | None = None) -> str:
|
||||||
"""Create an admin reset link and log it with the provided message."""
|
"""Log a reset link message and return the URL."""
|
||||||
token = passphrase.generate()
|
return log_reset_link(passphrase, message)
|
||||||
expiry = authsession.reset_expires()
|
|
||||||
await globals.db.instance.create_reset_token(
|
|
||||||
user_uuid=user_uuid,
|
|
||||||
key=tokens.reset_key(token),
|
|
||||||
expiry=expiry,
|
|
||||||
token_type=session_type,
|
|
||||||
)
|
|
||||||
reset_link = hostutil.reset_link_url(token)
|
|
||||||
logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
|
|
||||||
return reset_link
|
|
||||||
|
|
||||||
|
|
||||||
async def bootstrap_system() -> dict:
|
|
||||||
"""
|
|
||||||
Bootstrap the entire system with default data.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Contains information about created entities and reset link
|
|
||||||
"""
|
|
||||||
# Create permission first - will fail if already exists
|
|
||||||
perm0 = Permission(id="auth:admin", display_name="Master Admin")
|
|
||||||
await globals.db.instance.create_permission(perm0)
|
|
||||||
|
|
||||||
org = Org(uuid7.create(), "Organization")
|
|
||||||
await globals.db.instance.create_organization(org)
|
|
||||||
|
|
||||||
# After creation, org.permissions now includes the auto-created org admin permission
|
|
||||||
# Allow this org to grant global admin explicitly
|
|
||||||
await globals.db.instance.add_permission_to_organization(str(org.uuid), perm0.id)
|
|
||||||
|
|
||||||
# Create an Administration role granting both org and global admin
|
|
||||||
# Compose permissions for Administration role: global admin + org admin auto-perm
|
|
||||||
role = Role(
|
|
||||||
uuid7.create(),
|
|
||||||
org.uuid,
|
|
||||||
"Administration",
|
|
||||||
permissions=[perm0.id, *org.permissions],
|
|
||||||
)
|
|
||||||
await globals.db.instance.create_role(role)
|
|
||||||
|
|
||||||
user = User(
|
|
||||||
uuid=uuid7.create(),
|
|
||||||
display_name="Admin",
|
|
||||||
role_uuid=role.uuid,
|
|
||||||
created_at=datetime.now(timezone.utc),
|
|
||||||
visits=0,
|
|
||||||
)
|
|
||||||
await globals.db.instance.create_user(user)
|
|
||||||
|
|
||||||
# Generate reset link and log it
|
|
||||||
reset_link = await _create_and_log_admin_reset_link(
|
|
||||||
user.uuid, "✅ Bootstrap completed!", "admin bootstrap"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"user": user,
|
|
||||||
"org": org,
|
|
||||||
"role": role,
|
|
||||||
"permissions": [
|
|
||||||
perm0,
|
|
||||||
*[Permission(id=p, display_name="") for p in org.permissions],
|
|
||||||
],
|
|
||||||
"reset_link": reset_link,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def check_admin_credentials() -> bool:
|
async def check_admin_credentials() -> bool:
|
||||||
@@ -115,36 +45,42 @@ async def check_admin_credentials() -> bool:
|
|||||||
bool: True if a reset link was created, False if admin already has credentials
|
bool: True if a reset link was created, False if admin already has credentials
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get permission organizations to find admin users
|
# Find the auth:admin permission
|
||||||
permission_orgs = await globals.db.instance.get_permission_organizations(
|
p = next(
|
||||||
"auth:admin"
|
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
|
||||||
)
|
)
|
||||||
|
if not p:
|
||||||
if not permission_orgs:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Get users from the first organization with admin permission
|
perm_uuid = p.uuid
|
||||||
org_users = await globals.db.instance.get_organization_users(
|
|
||||||
str(permission_orgs[0].uuid)
|
# Find all roles that have the auth:admin permission
|
||||||
)
|
admin_roles = [
|
||||||
admin_users = [user for user, role in org_users if role == "Administration"]
|
r for r in db.data().roles.values() if perm_uuid in r.permissions
|
||||||
|
]
|
||||||
|
|
||||||
|
# Collect all users from those roles
|
||||||
|
admin_users = []
|
||||||
|
for role in admin_roles:
|
||||||
|
admin_users.extend(role.users)
|
||||||
|
|
||||||
if not admin_users:
|
if not admin_users:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check first admin user for credentials
|
# Check first admin user for credentials
|
||||||
admin_user = admin_users[0]
|
admin_user = admin_users[0]
|
||||||
credentials = await globals.db.instance.get_credentials_by_user_uuid(
|
|
||||||
admin_user.uuid
|
|
||||||
)
|
|
||||||
|
|
||||||
if not credentials:
|
if not admin_user.credential_ids:
|
||||||
# Admin exists but has no credentials, create reset link
|
# Admin exists but has no credentials, create reset link
|
||||||
await _create_and_log_admin_reset_link(
|
logger.info("⚠️ Admin user has no credentials!")
|
||||||
admin_user.uuid,
|
|
||||||
"⚠️ Admin user has no credentials!",
|
expiry = authsession.reset_expires()
|
||||||
"admin registration",
|
token = db.create_reset_token(
|
||||||
|
user_uuid=admin_user.uuid,
|
||||||
|
expiry=expiry,
|
||||||
|
token_type="admin registration",
|
||||||
)
|
)
|
||||||
|
_log_reset_link(token)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -153,38 +89,20 @@ async def check_admin_credentials() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def bootstrap_if_needed() -> bool:
|
async def bootstrap_if_needed(config: Config | None = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if system needs bootstrapping and perform it if necessary.
|
Check if admin needs credentials and create a reset link if needed.
|
||||||
|
|
||||||
|
Database bootstrapping itself is now handled automatically during
|
||||||
|
``db.init()`` via the registered kanta bootstrap callback. This function
|
||||||
|
remains as a post-init hook for credential checks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Kept for backwards compatibility; config is now applied during
|
||||||
|
``db.init()``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if bootstrapping was performed, False if system was already set up
|
bool: Always returns False (bootstrapping is performed during init).
|
||||||
"""
|
"""
|
||||||
try:
|
await check_admin_credentials()
|
||||||
# Check if the admin permission exists - if it does, system is already bootstrapped
|
return False
|
||||||
await globals.db.instance.get_permission("auth:admin")
|
|
||||||
# Permission exists, system is already bootstrapped
|
|
||||||
# Check if admin needs credentials (only for already-bootstrapped systems)
|
|
||||||
await check_admin_credentials()
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
# Permission doesn't exist, need to bootstrap
|
|
||||||
pass
|
|
||||||
|
|
||||||
# No admin permission found, need to bootstrap
|
|
||||||
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
|
|
||||||
await bootstrap_system()
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# CLI interface
|
|
||||||
async def main():
|
|
||||||
"""Main CLI entry point for bootstrapping."""
|
|
||||||
# Configure logging for CLI usage
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
|
||||||
|
|
||||||
await globals.init()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
# Shared configuration constants for session management.
|
# Shared configuration constants for session management.
|
||||||
@@ -6,20 +5,3 @@ SESSION_LIFETIME = timedelta(hours=24)
|
|||||||
|
|
||||||
# Lifetime for reset links created by admins
|
# Lifetime for reset links created by admins
|
||||||
RESET_LIFETIME = timedelta(days=14)
|
RESET_LIFETIME = timedelta(days=14)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PaskiaConfig:
|
|
||||||
"""Runtime configuration for the Paskia authentication server."""
|
|
||||||
|
|
||||||
rp_id: str
|
|
||||||
rp_name: str | None
|
|
||||||
origins: list[str] | None
|
|
||||||
auth_host: str | None
|
|
||||||
site_url: str # Base URL without trailing path (e.g. https://example.com)
|
|
||||||
site_path: str # Path to auth UI: "/" if auth_host, else "/auth/"
|
|
||||||
# Listen address (one of host:port or uds)
|
|
||||||
host: str | None = None
|
|
||||||
port: int | None = None
|
|
||||||
uds: str | None = None
|
|
||||||
devmode: bool = False
|
|
||||||
|
|||||||
+124
-403
@@ -1,415 +1,136 @@
|
|||||||
"""
|
"""
|
||||||
Database module for WebAuthn passkey authentication.
|
Database module for WebAuthn passkey authentication.
|
||||||
|
|
||||||
This module provides dataclasses and database abstractions for managing
|
Read: Access data() directly for structs.
|
||||||
users, credentials, and sessions in a WebAuthn authentication system.
|
CTX: data().session_ctx(key) returns SessionContext with effective permissions.
|
||||||
|
Write: Functions validate and commit, or raise ValueError.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from paskia import db
|
||||||
|
|
||||||
|
# Read (after init)
|
||||||
|
user_data = db.data().users[user_uuid]
|
||||||
|
|
||||||
|
# Context
|
||||||
|
ctx = db.data().session_ctx(session_key)
|
||||||
|
|
||||||
|
# Write
|
||||||
|
db.create_user(user)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
import paskia.db.operations as operations
|
||||||
from dataclasses import dataclass, field
|
from paskia.db.bootstrap import bootstrap
|
||||||
from datetime import datetime
|
from paskia.db.operations import (
|
||||||
from uuid import UUID
|
add_permission_to_org,
|
||||||
|
add_permission_to_role,
|
||||||
|
create_credential,
|
||||||
@dataclass
|
create_credential_session,
|
||||||
class Permission:
|
create_oid_client,
|
||||||
id: str # String primary key (max 128 chars)
|
create_org,
|
||||||
display_name: str
|
create_permission,
|
||||||
|
create_reset_token,
|
||||||
|
create_role,
|
||||||
@dataclass
|
create_user,
|
||||||
class Role:
|
delete_credential,
|
||||||
uuid: UUID
|
delete_oid_client,
|
||||||
org_uuid: UUID
|
delete_org,
|
||||||
display_name: str
|
delete_permission,
|
||||||
# List of permission IDs this role grants to its members
|
delete_reset_token,
|
||||||
permissions: list[str] = field(default_factory=list) # permission IDs
|
delete_role,
|
||||||
|
delete_session,
|
||||||
|
delete_sessions_for_user,
|
||||||
@dataclass
|
delete_user,
|
||||||
class Org:
|
is_username_taken,
|
||||||
uuid: UUID
|
login,
|
||||||
display_name: str
|
oidc_login,
|
||||||
# All permission IDs that the Org is allowed to grant to its roles
|
remove_permission_from_org,
|
||||||
permissions: list[str] = field(default_factory=list) # permission IDs
|
remove_permission_from_role,
|
||||||
# Roles belonging to this org
|
reset_oid_client_secret,
|
||||||
roles: list[Role] = field(default_factory=list)
|
set_session_host,
|
||||||
|
update_config,
|
||||||
|
update_credential_sign_count,
|
||||||
@dataclass
|
update_oid_client,
|
||||||
class User:
|
update_org_name,
|
||||||
uuid: UUID
|
update_permission,
|
||||||
display_name: str
|
update_role_name,
|
||||||
role_uuid: UUID
|
update_session,
|
||||||
created_at: datetime | None = None
|
update_user_display_name,
|
||||||
last_seen: datetime | None = None
|
update_user_info,
|
||||||
visits: int = 0
|
update_user_role,
|
||||||
|
)
|
||||||
|
from paskia.db.structs import (
|
||||||
@dataclass
|
DB,
|
||||||
class Credential:
|
Client,
|
||||||
uuid: UUID
|
Config,
|
||||||
credential_id: bytes # Long binary ID passed from the authenticator
|
Credential,
|
||||||
user_uuid: UUID
|
Org,
|
||||||
aaguid: UUID
|
Permission,
|
||||||
public_key: bytes
|
ResetToken,
|
||||||
sign_count: int
|
Role,
|
||||||
created_at: datetime
|
Session,
|
||||||
last_used: datetime | None = None
|
SessionContext,
|
||||||
last_verified: datetime | None = None
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Session:
|
def data() -> DB:
|
||||||
key: bytes
|
"""Get the database instance for direct read access."""
|
||||||
user_uuid: UUID
|
return operations._db
|
||||||
credential_uuid: UUID
|
|
||||||
host: str
|
|
||||||
ip: str
|
|
||||||
user_agent: str
|
|
||||||
renewed: datetime
|
|
||||||
|
|
||||||
def metadata(self) -> dict:
|
|
||||||
"""Return session metadata for backwards compatibility."""
|
|
||||||
return {
|
|
||||||
"ip": self.ip,
|
|
||||||
"user_agent": self.user_agent,
|
|
||||||
"renewed": self.renewed.isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ResetToken:
|
|
||||||
key: bytes
|
|
||||||
user_uuid: UUID
|
|
||||||
expiry: datetime
|
|
||||||
token_type: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SessionContext:
|
|
||||||
session: Session
|
|
||||||
user: User
|
|
||||||
org: Org
|
|
||||||
role: Role
|
|
||||||
credential: Credential | None = None
|
|
||||||
permissions: list[Permission] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class DatabaseInterface(ABC):
|
|
||||||
"""Abstract base class defining the database interface.
|
|
||||||
|
|
||||||
This class defines the public API that database implementations should provide.
|
|
||||||
Implementations may use decorators like @with_session that modify method signatures
|
|
||||||
at runtime, so this interface focuses on the logical operations rather than
|
|
||||||
exact parameter matching.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def init_db(self) -> None:
|
|
||||||
"""Initialize database tables."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
# User operations
|
|
||||||
@abstractmethod
|
|
||||||
async def get_user_by_uuid(self, user_uuid: UUID) -> User:
|
|
||||||
"""Get user record by WebAuthn user UUID."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def create_user(self, user: User) -> None:
|
|
||||||
"""Create a new user."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def update_user_display_name(
|
|
||||||
self, user_uuid: UUID, display_name: str
|
|
||||||
) -> None:
|
|
||||||
"""Update a user's display name."""
|
|
||||||
|
|
||||||
# Role operations
|
|
||||||
@abstractmethod
|
|
||||||
async def create_role(self, role: Role) -> None:
|
|
||||||
"""Create new role."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def update_role(self, role: Role) -> None:
|
|
||||||
"""Update a role's display name and synchronize its permissions."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def delete_role(self, role_uuid: UUID) -> None:
|
|
||||||
"""Delete a role by UUID. Implementations may prevent deletion if users exist."""
|
|
||||||
|
|
||||||
# Credential operations
|
|
||||||
@abstractmethod
|
|
||||||
async def create_credential(self, credential: Credential) -> None:
|
|
||||||
"""Store a credential for a user."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_credential_by_id(self, credential_id: bytes) -> Credential:
|
|
||||||
"""Get credential by credential ID."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_credentials_by_user_uuid(self, user_uuid: UUID) -> list[bytes]:
|
|
||||||
"""Get all credential IDs for a user."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def update_credential(self, credential: Credential) -> None:
|
|
||||||
"""Update the sign count, created_at, last_used, and last_verified for a credential."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def delete_credential(self, uuid: UUID, user_uuid: UUID) -> None:
|
|
||||||
"""Delete a specific credential for a user."""
|
|
||||||
|
|
||||||
# Session operations
|
|
||||||
@abstractmethod
|
|
||||||
async def create_session(
|
|
||||||
self,
|
|
||||||
user_uuid: UUID,
|
|
||||||
key: bytes,
|
|
||||||
credential_uuid: UUID,
|
|
||||||
host: str,
|
|
||||||
ip: str,
|
|
||||||
user_agent: str,
|
|
||||||
renewed: datetime,
|
|
||||||
) -> None:
|
|
||||||
"""Create a new session."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_session(self, key: bytes) -> Session | None:
|
|
||||||
"""Get session by key."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def delete_session(self, key: bytes) -> None:
|
|
||||||
"""Delete session by key."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def update_session(
|
|
||||||
self,
|
|
||||||
key: bytes,
|
|
||||||
*,
|
|
||||||
ip: str,
|
|
||||||
user_agent: str,
|
|
||||||
renewed: datetime,
|
|
||||||
) -> Session | None:
|
|
||||||
"""Update session metadata and touch renewed timestamp."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def set_session_host(self, key: bytes, host: str) -> None:
|
|
||||||
"""Bind a session to a specific host if not already set."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]:
|
|
||||||
"""Return all sessions for a user (including other hosts)."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def cleanup(self) -> None:
|
|
||||||
"""Called periodically to clean up expired records."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def delete_sessions_for_user(self, user_uuid: UUID) -> None:
|
|
||||||
"""Delete all sessions belonging to the provided user."""
|
|
||||||
|
|
||||||
# Reset token operations
|
|
||||||
@abstractmethod
|
|
||||||
async def create_reset_token(
|
|
||||||
self,
|
|
||||||
user_uuid: UUID,
|
|
||||||
key: bytes,
|
|
||||||
expiry: datetime,
|
|
||||||
token_type: str,
|
|
||||||
) -> None:
|
|
||||||
"""Create a reset token for a user."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_reset_token(self, key: bytes) -> ResetToken | None:
|
|
||||||
"""Retrieve a reset token by key."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def delete_reset_token(self, key: bytes) -> None:
|
|
||||||
"""Delete a reset token by key."""
|
|
||||||
|
|
||||||
# Organization operations
|
|
||||||
@abstractmethod
|
|
||||||
async def create_organization(self, org: Org) -> None:
|
|
||||||
"""Add a new organization."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_organization(self, org_id: str) -> Org:
|
|
||||||
"""Get organization by ID, including its permission IDs and roles (with their permission IDs)."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def list_organizations(self) -> list[Org]:
|
|
||||||
"""List all organizations with their roles and permission IDs."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def update_organization(self, org: Org) -> None:
|
|
||||||
"""Update organization options."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def delete_organization(self, org_uuid: UUID) -> None:
|
|
||||||
"""Delete organization by ID."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def add_user_to_organization(
|
|
||||||
self, user_uuid: UUID, org_id: str, role: str
|
|
||||||
) -> None:
|
|
||||||
"""Set a user's organization and role."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def transfer_user_to_organization(
|
|
||||||
self, user_uuid: UUID, new_org_id: str, new_role: str | None = None
|
|
||||||
) -> None:
|
|
||||||
"""Transfer a user to another organization with an optional role."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_user_organization(self, user_uuid: UUID) -> tuple[Org, str]:
|
|
||||||
"""Get the organization and role for a user."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_organization_users(self, org_id: str) -> list[tuple[User, str]]:
|
|
||||||
"""Get all users in an organization with their roles."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_roles_by_organization(self, org_id: str) -> list[Role]:
|
|
||||||
"""List roles belonging to an organization."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_user_role_in_organization(
|
|
||||||
self, user_uuid: UUID, org_id: str
|
|
||||||
) -> str | None:
|
|
||||||
"""Get a user's role in a specific organization."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def update_user_role_in_organization(
|
|
||||||
self, user_uuid: UUID, new_role: str
|
|
||||||
) -> None:
|
|
||||||
"""Update a user's role in their organization."""
|
|
||||||
|
|
||||||
# Permission operations
|
|
||||||
@abstractmethod
|
|
||||||
async def create_permission(self, permission: Permission) -> None:
|
|
||||||
"""Create a new permission."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_permission(self, permission_id: str) -> Permission:
|
|
||||||
"""Get permission by ID."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def list_permissions(self) -> list[Permission]:
|
|
||||||
"""List all permissions."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def update_permission(self, permission: Permission) -> None:
|
|
||||||
"""Update permission details."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def delete_permission(self, permission_id: str) -> None:
|
|
||||||
"""Delete permission by ID."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def rename_permission(
|
|
||||||
self, old_id: str, new_id: str, display_name: str
|
|
||||||
) -> None:
|
|
||||||
"""Rename a permission's ID (and display name) updating all references.
|
|
||||||
|
|
||||||
This must update:
|
|
||||||
- permissions.id (primary key)
|
|
||||||
- org_permissions.permission_id
|
|
||||||
- role_permissions.permission_id
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def add_permission_to_organization(
|
|
||||||
self, org_id: str, permission_id: str
|
|
||||||
) -> None:
|
|
||||||
"""Add a permission to an organization."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def remove_permission_from_organization(
|
|
||||||
self, org_id: str, permission_id: str
|
|
||||||
) -> None:
|
|
||||||
"""Remove a permission from an organization."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_organization_permissions(self, org_id: str) -> list[Permission]:
|
|
||||||
"""Get all permissions assigned to an organization."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_permission_organizations(self, permission_id: str) -> list[Org]:
|
|
||||||
"""Get all organizations that have a specific permission."""
|
|
||||||
|
|
||||||
# Role-permission operations
|
|
||||||
@abstractmethod
|
|
||||||
async def add_permission_to_role(self, role_uuid: UUID, permission_id: str) -> None:
|
|
||||||
"""Add a permission to a role."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def remove_permission_from_role(
|
|
||||||
self, role_uuid: UUID, permission_id: str
|
|
||||||
) -> None:
|
|
||||||
"""Remove a permission from a role."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_role_permissions(self, role_uuid: UUID) -> list[Permission]:
|
|
||||||
"""List all permissions granted to a role."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_permission_roles(self, permission_id: str) -> list[Role]:
|
|
||||||
"""List all roles that grant a permission."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_role(self, role_uuid: UUID) -> Role:
|
|
||||||
"""Get a role by UUID, including its permission IDs."""
|
|
||||||
|
|
||||||
# Combined operations
|
|
||||||
@abstractmethod
|
|
||||||
async def login(self, user_uuid: UUID, credential: Credential) -> None:
|
|
||||||
"""Update user and credential timestamps after successful login."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def create_user_and_credential(
|
|
||||||
self, user: User, credential: Credential
|
|
||||||
) -> None:
|
|
||||||
"""Create a new user and their first credential in a transaction."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_session_context(
|
|
||||||
self, session_key: bytes, host: str | None = None
|
|
||||||
) -> SessionContext | None:
|
|
||||||
"""Get complete session context including user, organization, role, and permissions."""
|
|
||||||
|
|
||||||
# Combined atomic operations
|
|
||||||
@abstractmethod
|
|
||||||
async def create_credential_session(
|
|
||||||
self,
|
|
||||||
user_uuid: UUID,
|
|
||||||
credential: Credential,
|
|
||||||
reset_key: bytes | None,
|
|
||||||
session_key: bytes,
|
|
||||||
*,
|
|
||||||
display_name: str | None = None,
|
|
||||||
host: str | None = None,
|
|
||||||
ip: str | None = None,
|
|
||||||
user_agent: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Atomically add a credential and create a session.
|
|
||||||
|
|
||||||
Steps (single transaction):
|
|
||||||
1. Insert credential
|
|
||||||
2. Optionally delete old reset token if provided
|
|
||||||
3. Optionally update user's display name
|
|
||||||
4. Insert new session referencing the credential
|
|
||||||
5. Update user's last_seen and increment visits (treat as a login)
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
# Types
|
||||||
|
"Config",
|
||||||
"Credential",
|
"Credential",
|
||||||
"Session",
|
"DB",
|
||||||
"ResetToken",
|
"Client",
|
||||||
"SessionContext",
|
|
||||||
"Org",
|
"Org",
|
||||||
"Role",
|
|
||||||
"Permission",
|
"Permission",
|
||||||
"DatabaseInterface",
|
"ResetToken",
|
||||||
|
"Role",
|
||||||
|
"Session",
|
||||||
|
"SessionContext",
|
||||||
|
"User",
|
||||||
|
# Instance
|
||||||
|
"data",
|
||||||
|
# Read ops
|
||||||
|
# Write ops
|
||||||
|
"add_permission_to_org",
|
||||||
|
"add_permission_to_role",
|
||||||
|
"bootstrap",
|
||||||
|
"create_credential",
|
||||||
|
"create_credential_session",
|
||||||
|
"create_org",
|
||||||
|
"create_permission",
|
||||||
|
"create_reset_token",
|
||||||
|
"create_role",
|
||||||
|
"create_user",
|
||||||
|
"delete_credential",
|
||||||
|
"delete_org",
|
||||||
|
"delete_permission",
|
||||||
|
"delete_reset_token",
|
||||||
|
"delete_role",
|
||||||
|
"delete_session",
|
||||||
|
"delete_sessions_for_user",
|
||||||
|
"delete_user",
|
||||||
|
"login",
|
||||||
|
"oidc_login",
|
||||||
|
"remove_permission_from_org",
|
||||||
|
"remove_permission_from_role",
|
||||||
|
"set_session_host",
|
||||||
|
"update_config",
|
||||||
|
"update_credential_sign_count",
|
||||||
|
"update_org_name",
|
||||||
|
"update_permission",
|
||||||
|
"update_role_name",
|
||||||
|
"update_session",
|
||||||
|
"update_user_display_name",
|
||||||
|
"update_user_info",
|
||||||
|
"update_user_role",
|
||||||
|
"is_username_taken",
|
||||||
|
# OIDC
|
||||||
|
"create_oid_client",
|
||||||
|
"update_oid_client",
|
||||||
|
"reset_oid_client_secret",
|
||||||
|
"delete_oid_client",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
Background task for database maintenance.
|
||||||
|
|
||||||
|
Kanta handles periodic flushing to disk. This module keeps a small
|
||||||
|
companion task that periodically cleans up expired sessions/tokens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from paskia.db.lifecycle import cleanup_expired
|
||||||
|
|
||||||
|
CLEANUP_INTERVAL = 1 # Expired item cleanup
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
_background_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _background_loop():
|
||||||
|
"""Background task that periodically cleans up expired items."""
|
||||||
|
# Run cleanup immediately on startup to clear old expired items
|
||||||
|
cleanup_expired()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(CLEANUP_INTERVAL)
|
||||||
|
cleanup_expired()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
_logger.debug("Error in database background loop", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_background():
|
||||||
|
"""Start the background cleanup task."""
|
||||||
|
global _background_task
|
||||||
|
|
||||||
|
# Check if task exists but is no longer running (e.g., after uvicorn reload)
|
||||||
|
if _background_task is not None:
|
||||||
|
if _background_task.done():
|
||||||
|
_logger.debug("Previous background task was done, restarting")
|
||||||
|
_background_task = None
|
||||||
|
else:
|
||||||
|
# Task exists and is running - but might be in a dead event loop
|
||||||
|
try:
|
||||||
|
# Check if task is in current event loop
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
task_loop = _background_task.get_loop()
|
||||||
|
if loop is task_loop:
|
||||||
|
# Task is already running in same loop - idempotent, just return
|
||||||
|
# This happens with dual IPv4+IPv6 endpoints sharing the same process
|
||||||
|
_logger.debug(
|
||||||
|
"Background task already running in same loop, skipping"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
_logger.debug("Background task in different event loop, restarting")
|
||||||
|
_background_task = None
|
||||||
|
except Exception as e:
|
||||||
|
_logger.debug("Error checking background task loop: %s, restarting", e)
|
||||||
|
_background_task = None
|
||||||
|
|
||||||
|
if _background_task is None:
|
||||||
|
_background_task = asyncio.create_task(_background_loop())
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_background():
|
||||||
|
"""Stop the background cleanup task."""
|
||||||
|
global _background_task
|
||||||
|
if _background_task:
|
||||||
|
_background_task.cancel()
|
||||||
|
try:
|
||||||
|
await _background_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
_background_task = None
|
||||||
|
|
||||||
|
|
||||||
|
# Aliases for backwards compatibility
|
||||||
|
start_cleanup = start_background
|
||||||
|
stop_cleanup = stop_background
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
Bootstrap operations for initial system setup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import uuid7
|
||||||
|
|
||||||
|
from paskia.authsession import reset_expires
|
||||||
|
from paskia.db.structs import DB, Config, Org, Permission, ResetToken, Role, User
|
||||||
|
from paskia.util.crypto import secret_key
|
||||||
|
from paskia.util.hostutil import reset_link_url
|
||||||
|
|
||||||
|
_reset_link_logger = logging.getLogger("paskia.reset_link")
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_reset_link_logger() -> None:
|
||||||
|
if _reset_link_logger.handlers:
|
||||||
|
return
|
||||||
|
handler = logging.StreamHandler(sys.stderr)
|
||||||
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
_reset_link_logger.addHandler(handler)
|
||||||
|
_reset_link_logger.setLevel(logging.INFO)
|
||||||
|
_reset_link_logger.propagate = False
|
||||||
|
|
||||||
|
|
||||||
|
_configure_reset_link_logger()
|
||||||
|
|
||||||
|
ADMIN_RESET_MESSAGE = """
|
||||||
|
👤 Admin %s
|
||||||
|
- Use this link to register a Passkey for the admin user!
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def log_reset_link(passphrase: str, message: str | None = None) -> str:
|
||||||
|
"""Log a reset link message and return the URL."""
|
||||||
|
reset_link = reset_link_url(passphrase)
|
||||||
|
if message:
|
||||||
|
_reset_link_logger.info(message)
|
||||||
|
_reset_link_logger.info(ADMIN_RESET_MESSAGE, reset_link)
|
||||||
|
return reset_link
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap(
|
||||||
|
data: DB,
|
||||||
|
org_name: str = "Organization",
|
||||||
|
admin_name: str = "Admin",
|
||||||
|
reset_passphrase: str | None = None,
|
||||||
|
reset_expiry: datetime | None = None,
|
||||||
|
config: Config | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Bootstrap the entire system by seeding an empty database.
|
||||||
|
|
||||||
|
This is intended to be called from a ``@kanta.bootstrap`` callback during
|
||||||
|
``kanta.open()``. It mutates the provided root ``data`` object directly;
|
||||||
|
kanta queues the resulting state as the initial "bootstrap" change record.
|
||||||
|
|
||||||
|
Creates:
|
||||||
|
- auth:admin permission (Master Admin)
|
||||||
|
- auth:org:admin permission (Org Admin)
|
||||||
|
- Organization with Administration role
|
||||||
|
- Admin user with Administration role
|
||||||
|
- Reset token for admin registration
|
||||||
|
- Config (if provided)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: The live root database object (usually a ``DB`` instance).
|
||||||
|
org_name: Display name for the organization (default: "Organization")
|
||||||
|
admin_name: Display name for the admin user (default: "Admin")
|
||||||
|
reset_passphrase: Passphrase for the reset token (generated if not provided)
|
||||||
|
reset_expiry: Expiry datetime for the reset token (default: 14 days)
|
||||||
|
config: Configuration to store (rp_id, rp_name, origins, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The reset passphrase for admin registration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Check if system is already bootstrapped
|
||||||
|
for p in data.permissions.values():
|
||||||
|
if p.scope == "auth:admin":
|
||||||
|
raise ValueError(
|
||||||
|
"System already bootstrapped (auth:admin permission exists)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate UUIDs upfront
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
perm_admin_uuid = uuid7.create(now)
|
||||||
|
perm_org_admin_uuid = uuid7.create(now)
|
||||||
|
org_uuid = uuid7.create(now)
|
||||||
|
role_uuid = uuid7.create(now)
|
||||||
|
user_uuid = uuid7.create(now)
|
||||||
|
|
||||||
|
# Set reset token expiry (passphrase generated by ResetToken.create)
|
||||||
|
if reset_expiry is None:
|
||||||
|
reset_expiry = reset_expires()
|
||||||
|
|
||||||
|
# Create auth:admin permission
|
||||||
|
perm_admin = Permission(
|
||||||
|
scope="auth:admin",
|
||||||
|
display_name="Master Admin",
|
||||||
|
orgs={org_uuid: True}, # Grant to org
|
||||||
|
)
|
||||||
|
perm_admin.uuid = perm_admin_uuid
|
||||||
|
|
||||||
|
# Create auth:org:admin permission
|
||||||
|
perm_org_admin = Permission(
|
||||||
|
scope="auth:org:admin",
|
||||||
|
display_name="Org Admin",
|
||||||
|
orgs={org_uuid: True}, # Grant to org
|
||||||
|
)
|
||||||
|
perm_org_admin.uuid = perm_org_admin_uuid
|
||||||
|
|
||||||
|
# Create organization
|
||||||
|
new_org = Org.create(display_name=org_name)
|
||||||
|
new_org.uuid = org_uuid
|
||||||
|
|
||||||
|
# Create Administration role with both permissions
|
||||||
|
admin_role = Role(
|
||||||
|
org_uuid=org_uuid,
|
||||||
|
display_name="Administration",
|
||||||
|
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
|
||||||
|
)
|
||||||
|
admin_role.uuid = role_uuid
|
||||||
|
|
||||||
|
# Create admin user
|
||||||
|
admin_user = User(
|
||||||
|
display_name=admin_name,
|
||||||
|
role_uuid=role_uuid,
|
||||||
|
created_at=now,
|
||||||
|
last_seen=None,
|
||||||
|
visits=0,
|
||||||
|
theme="",
|
||||||
|
)
|
||||||
|
admin_user.uuid = user_uuid
|
||||||
|
|
||||||
|
# Create reset token
|
||||||
|
reset_token, reset_passphrase = ResetToken.create(
|
||||||
|
user=user_uuid,
|
||||||
|
expiry=reset_expiry,
|
||||||
|
token_type="admin bootstrap",
|
||||||
|
passphrase=reset_passphrase,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set config if provided
|
||||||
|
if config is not None:
|
||||||
|
data.config = config
|
||||||
|
|
||||||
|
# Generate OIDC signing key
|
||||||
|
data.oidc.key = secret_key()
|
||||||
|
|
||||||
|
# Store all bootstrapped objects in the live data object
|
||||||
|
data.permissions[perm_admin_uuid] = perm_admin
|
||||||
|
data.permissions[perm_org_admin_uuid] = perm_org_admin
|
||||||
|
data.orgs[org_uuid] = new_org
|
||||||
|
data.roles[role_uuid] = admin_role
|
||||||
|
data.users[user_uuid] = admin_user
|
||||||
|
data.reset_tokens[reset_token.key] = reset_token
|
||||||
|
|
||||||
|
return reset_passphrase
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""
|
||||||
|
Database lifecycle: initialization and maintenance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated, Any, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from kanta import Kanta
|
||||||
|
from kanta.exceptions import DatabaseError
|
||||||
|
|
||||||
|
import paskia.db.operations as _ops
|
||||||
|
from paskia import oidc_notify
|
||||||
|
from paskia.authsession import EXPIRES
|
||||||
|
from paskia.db.bootstrap import bootstrap, log_reset_link
|
||||||
|
from paskia.db.paths import db_file_path
|
||||||
|
from paskia.db.structs import DB
|
||||||
|
from paskia.util.runtime import config as runtime_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
runtime = runtime_config()
|
||||||
|
if runtime is None:
|
||||||
|
raise RuntimeError("PASKIA_CONFIG must be defined before importing db.lifecycle")
|
||||||
|
|
||||||
|
kanta = Kanta(
|
||||||
|
str(db_file_path(rp_id=runtime.config.rp_id, create_root=False)),
|
||||||
|
_ops._db,
|
||||||
|
migrations="paskia.db.migrations",
|
||||||
|
)
|
||||||
|
kanta.ctx.rp_id = runtime.config.rp_id
|
||||||
|
_ops._db._store = kanta
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_uuid_in_state(state: dict | None, uuid_str: str) -> str | None:
|
||||||
|
"""Resolve UUID to label from serialized state dict."""
|
||||||
|
if not state:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Display-name based entities.
|
||||||
|
for bucket in ("users", "orgs", "roles", "permissions"):
|
||||||
|
entity = state.get(bucket, {}).get(uuid_str)
|
||||||
|
if isinstance(entity, dict):
|
||||||
|
display_name = entity.get("display_name")
|
||||||
|
if isinstance(display_name, str) and display_name:
|
||||||
|
return display_name
|
||||||
|
|
||||||
|
# OIDC clients use "name" instead of "display_name".
|
||||||
|
client = state.get("oidc", {}).get("clients", {}).get(uuid_str)
|
||||||
|
if isinstance(client, dict):
|
||||||
|
name = client.get("name")
|
||||||
|
if isinstance(name, str) and name:
|
||||||
|
return name
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_uuid_label(
|
||||||
|
uuid_str: str,
|
||||||
|
*,
|
||||||
|
previous: dict | None = None,
|
||||||
|
current: dict | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Resolve known entity UUIDs to human-readable labels."""
|
||||||
|
# Prefer previous state so deletions/renames still show a useful label.
|
||||||
|
label = _lookup_uuid_in_state(previous, uuid_str)
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
|
label = _lookup_uuid_in_state(current, uuid_str)
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
|
|
||||||
|
try:
|
||||||
|
uid = UUID(uuid_str)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if uid in _ops._db.users:
|
||||||
|
return _ops._db.users[uid].display_name
|
||||||
|
if uid in _ops._db.orgs:
|
||||||
|
return _ops._db.orgs[uid].display_name
|
||||||
|
if uid in _ops._db.roles:
|
||||||
|
return _ops._db.roles[uid].display_name
|
||||||
|
if uid in _ops._db.permissions:
|
||||||
|
return _ops._db.permissions[uid].display_name
|
||||||
|
if uid in _ops._db.oidc.clients:
|
||||||
|
return _ops._db.oidc.clients[uid].name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@kanta.logfmt
|
||||||
|
def format_log_uuid(
|
||||||
|
value: Any,
|
||||||
|
path: str,
|
||||||
|
previous: Annotated[dict, "pre"] | None = None,
|
||||||
|
current: Annotated[dict, "post"] | None = None,
|
||||||
|
) -> Optional[str]: # noqa: UP045
|
||||||
|
"""Format UUID values/keys/actor labels and censor secrets in transaction logs."""
|
||||||
|
# Censor sensitive OIDC key material regardless of value type, but only
|
||||||
|
# when formatting the value: path components are passed with the component
|
||||||
|
# itself as value and must stay visible ("oidc.key = <hidden>").
|
||||||
|
if (path == "oidc.key" or path.endswith(".oidc.key")) and value != "key":
|
||||||
|
return "<hidden>"
|
||||||
|
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Works for transaction actor metadata ($user), values, and path components.
|
||||||
|
return _resolve_uuid_label(value, previous=previous, current=current)
|
||||||
|
|
||||||
|
|
||||||
|
@kanta.fatal_error
|
||||||
|
def terminate(error: DatabaseError) -> None:
|
||||||
|
"""Fatal error callback: terminate the process on background write failures."""
|
||||||
|
logger.error("Fatal database error: %s", error)
|
||||||
|
os.kill(os.getpid(), signal.SIGTERM)
|
||||||
|
|
||||||
|
|
||||||
|
@kanta.bootstrap
|
||||||
|
def bootstrap_db(data: DB) -> None:
|
||||||
|
reset_passphrase = bootstrap(data, config=runtime.config)
|
||||||
|
log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
|
||||||
|
|
||||||
|
|
||||||
|
async def init():
|
||||||
|
"""Load database from JSONL file using kanta.
|
||||||
|
|
||||||
|
If the database file is empty, the configured bootstrap callback seeds it
|
||||||
|
with default permissions, organization, role, admin user and a reset token.
|
||||||
|
"""
|
||||||
|
rootpath = Path(kanta.filename).parent
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(rootpath.mkdir, parents=True, exist_ok=True)
|
||||||
|
await kanta.open()
|
||||||
|
except Exception as e:
|
||||||
|
raise SystemExit(f"{e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_expired() -> int:
|
||||||
|
"""Remove expired sessions and reset tokens. Returns count removed."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
limit = now - EXPIRES
|
||||||
|
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.validated < limit]
|
||||||
|
if expired_sessions:
|
||||||
|
oidc_notify.schedule_notifications(expired_sessions)
|
||||||
|
with kanta.transaction("expiry"):
|
||||||
|
for k in expired_sessions:
|
||||||
|
del _ops._db.sessions[k]
|
||||||
|
expired_tokens = [k for k, t in _ops._db.reset_tokens.items() if t.expiry < now]
|
||||||
|
for k in expired_tokens:
|
||||||
|
del _ops._db.reset_tokens[k]
|
||||||
|
return len(expired_sessions) + len(expired_tokens)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
Database schema migrations.
|
||||||
|
|
||||||
|
Migrations are applied during database load based on the version field.
|
||||||
|
Each migration should be idempotent and only run when needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from kanta import Kanta
|
||||||
|
|
||||||
|
from paskia.util.crypto import secret_key
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_v1(d: dict) -> None:
|
||||||
|
"""Remove Org.created_at fields."""
|
||||||
|
for org_data in d["orgs"].values():
|
||||||
|
org_data.pop("created_at", None)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_v2(d: dict, kanta: Kanta) -> None:
|
||||||
|
"""Add config field if missing."""
|
||||||
|
if "config" not in d:
|
||||||
|
d["config"] = {"rp_id": kanta.ctx.rp_id}
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_v3(d: dict) -> None:
|
||||||
|
"""Ensure all users have visits field."""
|
||||||
|
for user_data in d["users"].values():
|
||||||
|
user_data.setdefault("visits", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_v4(d: dict) -> None:
|
||||||
|
"""OpenID Connect support and hardened session keys."""
|
||||||
|
# Session keys changed to hashes, drop old sessions
|
||||||
|
d["sessions"] = {}
|
||||||
|
# Create OIDC structure with a generated new key
|
||||||
|
d["oidc"] = {
|
||||||
|
"clients": {},
|
||||||
|
"key": base64.standard_b64encode(secret_key()).decode(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_v5(d: dict) -> None:
|
||||||
|
"""Convert config.listen from str to list[str] if needed."""
|
||||||
|
listen = d["config"].get("listen")
|
||||||
|
if listen and isinstance(listen, str):
|
||||||
|
d["config"]["listen"] = [listen]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user