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 |
+2
-1
@@ -5,7 +5,8 @@ dist/
|
||||
*.lock
|
||||
package-lock.json
|
||||
paskia.sqlite
|
||||
paskia.jsonl
|
||||
*.paskiadb
|
||||
*.data
|
||||
/paskia/frontend-build
|
||||
/paskia/_version.py
|
||||
coverage-html/
|
||||
|
||||
@@ -1,64 +1,225 @@
|
||||
# Paskia
|
||||
|
||||

|
||||
|
||||
An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
|
||||
|
||||
## What is Paskia?
|
||||
|
||||
- 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
|
||||
* Master admin can create multiple independent orgs
|
||||
* Master admin makes permissions available for orgs to assign
|
||||
- User Profile and Administration by API and web interface.
|
||||
under `/auth/` or `auth.example.com`
|
||||
- Reset tokens and additional device linking via QR code or codewords.
|
||||
- Pure Python, FastAPI, packaged with prebuilt Vue frontend
|
||||
* Multiple independent orgs
|
||||
* Master admin can do everything or delegate to org admins
|
||||
- User Profile and Admin by API and web interface
|
||||
- Implements login/reauth/forbidden flows for you
|
||||
- Single Sign-On (SSO): Users register once and authenticate across your services
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
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
|
||||
|
||||
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
|
||||
|
||||
```fish
|
||||
uvx paskia serve --rp-id example.com
|
||||
```sh
|
||||
uvx paskia --rp-id example.com
|
||||
```
|
||||
|
||||
On the first run it downloads the software and prints a registration link for the Admin. The server will start up on [localhost:4401](http://localhost:4401) *for authentication required*, serving for `*.example.com`. 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`.
|
||||
|
||||
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 (see documentation below).
|
||||
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).
|
||||
|
||||
For a permanent install of `paskia` CLI command, not needing `uvx`:
|
||||
|
||||
```fish
|
||||
```sh
|
||||
uv tool install paskia
|
||||
```
|
||||
|
||||
## 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
|
||||
paskia serve [options]
|
||||
paskia [options]
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| Listen address | One of *host***:***port* (default all hosts, port 4401) or **unix:***path***/paskia.socket** (Unix socket) | **localhost:4401** |
|
||||
| --rp-id *domain* | Main/top domain | **localhost** |
|
||||
| --rp-name *"text"* | Name of your company or site | Same as rp-id |
|
||||
| --origin *url* | Explicitly list the domain names served | **https://**_rp-id_ |
|
||||
| --auth-host *domain* | Dedicated authentication site (e.g., **auth.example.com**) | **Unspecified:** we use **/auth/** on **every** site under rp-id.|
|
||||
| -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) |
|
||||
|
||||
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.
|
||||
|
||||
## Tutorial: From Local Testing to Production
|
||||
|
||||
This section walks you through a complete example, from running Paskia locally to protecting a real site in production.
|
||||
|
||||
### 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
|
||||
|
||||
- [Caddy configuration](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/Caddy.md)
|
||||
- [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)
|
||||
|
||||
+72
-35
@@ -1,64 +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 | Notes |
|
||||
| Method | Path | What it is for | Responses |
|
||||
|---:|---|---|---|
|
||||
| GET | `/auth/` | User profile page | |
|
||||
| GET | `/auth/admin/` | Admin panel | Requires auth:admin (master) or org admin permissions. |
|
||||
| GET | `/auth/{token}` | Reset / add credential URL (QR code link) | E.g. `/auth/fun.cotton.fresh.xray.lava` |
|
||||
| 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/*`
|
||||
### Public JSON API: /auth/api/*
|
||||
|
||||
| Method | Path | Used for | Notes |
|
||||
| Method | Path | Used for | Responses |
|
||||
|---:|---|---|---|
|
||||
| GET | `/auth/api/settings` | Paskia configuration | Returns RP info + base paths + session cookie name |
|
||||
| POST | `/auth/api/user-info` | Full user profile | Basic information, credentials, sessions, permissions |
|
||||
| POST | `/auth/api/logout` | Terminate session and delete session cookie | Signs out of the current site |
|
||||
| POST | `/auth/api/validate` | Validate and renew session cookie | Optional query: `perm=` (repeatable), `max_age=` |
|
||||
| GET | `/auth/api/forward` | Validate access (Caddy/Nginx) | 204 on success; 401/403 otherwise (HTML if requested) |
|
||||
| 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|
|
||||
|
||||
The `validate` and `forward` endpoints take query arguments `perm=` and `max_age=` for specific requirements on the validation of the current session.
|
||||
### User JSON API: /auth/api/user/*
|
||||
|
||||
### User JSON API: `/auth/api/user/*`
|
||||
|
||||
| Method | Path | Used for | Notes |
|
||||
| Method | Path | Used for | Responses |
|
||||
|---:|---|---|---|
|
||||
| PUT | `/auth/api/user/display-name` | Update the user’s display name | Body: JSON `{ "display_name": "..." }` |
|
||||
| POST | `/auth/api/user/logout-all` | Terminate all user sessions | Clears current host cookie |
|
||||
| DELETE | `/auth/api/user/session/{session_id}` | Terminate one session | Session IDs are server-issued |
|
||||
| DELETE | `/auth/api/user/credential/{uuid}` | Delete a credential | Requires recent authentication |
|
||||
| POST | `/auth/api/user/create-link` | Create a device-add link | Requires recent authentication |
|
||||
| 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 and modify the current user.
|
||||
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/*`
|
||||
### 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.
|
||||
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.
|
||||
|
||||
### WebSockets: `/auth/ws/*`
|
||||
| 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 |
|
||||
| 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`)
|
||||
### Auth host mode (--auth-host)
|
||||
|
||||
#### On the auth host:
|
||||
- The Web UI is served at site root (e.g. admin UI at `/admin/`), and the `/auth/...` equivalents (e.g. `/auth/admin/`) redirect to the 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.
|
||||
- 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
|
||||
- `/auth/api/*` is served normally.
|
||||
- `/auth/api/user/*`, `/auth/api/admin/*`, and `/auth/ws/*` don't exist.
|
||||
- /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.
|
||||
|
||||
+12
-10
@@ -1,16 +1,18 @@
|
||||
# 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) |
|
||||
| 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.
|
||||
|
||||
@@ -18,6 +20,6 @@ When a request is allowed, the auth service adds these headers by the forward-au
|
||||
|
||||
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 our [Caddy configuration](Caddy.md) to avoid dealing with any fake headers.
|
||||
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.
|
||||
|
||||
+166
-32
@@ -1,43 +1,177 @@
|
||||
# Integrating Paskia with your App
|
||||
|
||||
Protect API routes with forward-auth (see [Caddy configuration](Caddy.md)). Optionally protect your app assets and not just the API.
|
||||
[API overview](API.md) · [Proxy guides](proxy/index.md)
|
||||
|
||||
Catch response status 401/403 in fetch calls to protected endpoints and implement authentication flow in this case. The response is JSON and contains `detail` (an error message describing what is needed) and `auth.iframe` (a URL). Render that URL in an iframe and retry the request after authentication (see below).
|
||||
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).
|
||||
|
||||
While the app is in (active) use, call `/auth/api/validate` occasionally to keep the session alive (session lifetime is 24h), otherwise the user will have to login every day. Max-age limits are unaffected by this and can be used on endpoints needing to reauthenticate with passkey more frequently.
|
||||
## Frontend Integration
|
||||
|
||||
Fetch `/auth/api/user-info` to display user/session details, or link to `/auth/` if you prefer using the built-in profile UI and not having to do anything more.
|
||||
### Using the paskia-js Module
|
||||
|
||||
## Authentication Flow (iframe)
|
||||
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.
|
||||
|
||||
```js
|
||||
// Show an authentication dialog
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.src = auth.url // from 401/403 response JSON
|
||||
iframe.style.cssText = `
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
z-index: 9999;
|
||||
background: transparent;
|
||||
backdrop-filter: blur(0.1rem) brightness(0.7);
|
||||
`
|
||||
document.body.appendChild(iframe)
|
||||
|
||||
// Wait until user is finished with the dialog
|
||||
const handler = ev => {
|
||||
if (ev.origin !== location.origin) return
|
||||
iframe.remove()
|
||||
removeEventListener('message', handler)
|
||||
if (ev.data?.type === 'auth-success') retry_original_fetch()
|
||||
}
|
||||
addEventListener('message', handler)
|
||||
```html
|
||||
<script type="module">
|
||||
import { apiJson, apiFetch, SessionValidator } from 'https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js'
|
||||
</script>
|
||||
```
|
||||
|
||||
This describes the frontend flow for handling 401/403 responses from endpoints protected by Paskia forward-auth, without ever exiting your app.
|
||||
Or install to your project:
|
||||
|
||||
When a protected request fails, the backend returns 401 (needs auth / reauth) or 403 (missing permission). For API requests, the response is JSON that includes an iframe URL. Your app should render that URL in a full-screen iframe overlay, and retry the request after the iframe reports success. If it reports `auth-cancel`, don't try again. The backdrop for the dialog is a stylistic choice, and you can style the background shown with the dialog any way you wish, and consider using CSS file with the iframe rather than inline styles as used in the example.
|
||||
```sh
|
||||
npm install paskia
|
||||
```
|
||||
|
||||
Following this flow the user gets authenticated properly and after that your app keeps running as if nothing ever happened.
|
||||
### 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/`.
|
||||
@@ -1,13 +1,15 @@
|
||||
# Paskia Caddy Configuration
|
||||
|
||||
[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`)
|
||||
[`/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.
|
||||
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:
|
||||
|
||||
@@ -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": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@types/bun": "^1.3.3",
|
||||
"@types/node": "*",
|
||||
"c8": "^10.1.3"
|
||||
}
|
||||
},
|
||||
@@ -92,11 +92,13 @@
|
||||
}
|
||||
},
|
||||
"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,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.57.0"
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -107,17 +109,11 @@
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "13.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",
|
||||
"integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==",
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
@@ -126,7 +122,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"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,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -176,14 +174,6 @@
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bun-types": {
|
||||
"version": "1.3.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/c8": {
|
||||
"version": "10.1.3",
|
||||
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
|
||||
@@ -412,6 +402,21 @@
|
||||
"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": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
@@ -426,6 +431,7 @@
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
"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,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -674,11 +680,13 @@
|
||||
}
|
||||
},
|
||||
"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,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.57.0"
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -691,7 +699,9 @@
|
||||
}
|
||||
},
|
||||
"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,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -712,9 +722,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@@ -894,6 +904,8 @@
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"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,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
||||
+8
-8
@@ -5,18 +5,18 @@
|
||||
"description": "E2E tests for Paskia using Playwright with Virtual Authenticator",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "bunx playwright test",
|
||||
"test:headed": "bunx playwright test --headed",
|
||||
"test:debug": "bunx playwright test --debug",
|
||||
"test:ui": "bunx playwright test --ui",
|
||||
"test:coverage": "COVERAGE=1 bunx playwright test",
|
||||
"report": "bunx playwright show-report",
|
||||
"install:browsers": "bunx playwright install chromium"
|
||||
"test": "npx playwright test",
|
||||
"test:headed": "npx playwright test --headed",
|
||||
"test:debug": "npx playwright test --debug",
|
||||
"test:ui": "npx playwright test --ui",
|
||||
"test:coverage": "COVERAGE=1 npx playwright test",
|
||||
"report": "npx playwright show-report",
|
||||
"install:browsers": "npx playwright install chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@types/bun": "^1.3.3",
|
||||
"@types/node": "*",
|
||||
"c8": "^10.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { defineConfig, devices } from '@playwright/test'
|
||||
* Playwright configuration for Paskia E2E tests.
|
||||
* Uses Chrome's Virtual Authenticator for automated passkey testing.
|
||||
*
|
||||
* Run with: bun run test
|
||||
* Run with: npm test
|
||||
*/
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
@@ -148,10 +148,10 @@ test.describe('Passkey Authentication E2E', () => {
|
||||
|
||||
const userInfo = await getUserInfo(page, baseUrl, sessionToken)
|
||||
|
||||
expect(userInfo.ctx.user.uuid).toBe(userUuid)
|
||||
expect(userInfo.ctx.user.display_name).toBe('Admin User')
|
||||
expect(userInfo.user.uuid).toBe(userUuid)
|
||||
expect(userInfo.user.display_name).toBe('Admin User')
|
||||
expect(userInfo.credentials).toBeDefined()
|
||||
expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1)
|
||||
expect(Object.keys(userInfo.credentials).length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Navigate to profile and take screenshot
|
||||
const cookieName = getSessionCookieName()
|
||||
@@ -169,8 +169,8 @@ test.describe('Passkey Authentication E2E', () => {
|
||||
await page.screenshot({ path: 'test-results/profile-view.png' })
|
||||
console.log('✓ Screenshot saved: test-results/profile-view.png')
|
||||
|
||||
console.log(`✓ User info retrieved: ${userInfo.ctx.user.display_name}`)
|
||||
console.log(`✓ Credentials count: ${userInfo.credentials.length}`)
|
||||
console.log(`✓ User info retrieved: ${userInfo.user.display_name}`)
|
||||
console.log(`✓ Credentials count: ${Object.keys(userInfo.credentials).length}`)
|
||||
})
|
||||
|
||||
test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => {
|
||||
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
logout,
|
||||
} from './fixtures/passkey-helpers'
|
||||
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.
|
||||
@@ -55,8 +61,18 @@ async function clearSessionCookie(page: Page): Promise<void> {
|
||||
/**
|
||||
* Set up the test page using the examples page directly.
|
||||
* 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> {
|
||||
// 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
|
||||
await page.goto(`${baseUrl}/auth/examples/`)
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> {
|
||||
await page.waitForSelector('#auth-iframe', { timeout })
|
||||
const iframe = page.frameLocator('#auth-iframe')
|
||||
await page.waitForSelector('#paskia-iframe', { timeout })
|
||||
const iframe = page.frameLocator('#paskia-iframe')
|
||||
// Wait for iframe content to load
|
||||
await iframe.locator('.view-root').waitFor({ timeout })
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -169,7 +185,7 @@ async function clickBackInIframe(page: Page): Promise<void> {
|
||||
* Click Login button in auth iframe.
|
||||
*/
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -177,7 +193,7 @@ async function clickLoginInIframe(page: Page): Promise<void> {
|
||||
* Click Verify button in auth iframe (for reauth mode).
|
||||
*/
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -185,7 +201,7 @@ async function clickVerifyInIframe(page: Page): Promise<void> {
|
||||
* Click Logout button in auth iframe (for forbidden mode).
|
||||
*/
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -200,11 +216,11 @@ test.describe('API Mode - 401 Login Flow', () => {
|
||||
await clearSessionCookie(page)
|
||||
|
||||
// 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')
|
||||
|
||||
// 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.getByRole('button', { name: 'Login' })).toBeVisible()
|
||||
|
||||
@@ -252,7 +268,7 @@ test.describe('API Mode - 401 Login Flow', () => {
|
||||
await setupTestHarness(page)
|
||||
|
||||
// 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
|
||||
await waitForAuthIframe(page)
|
||||
@@ -268,7 +284,7 @@ test.describe('API Mode - 401 Login Flow', () => {
|
||||
// Wait for API call to complete and verify result
|
||||
const result = await apiCallPromise
|
||||
expect(result.status).toBe(200)
|
||||
expect(result.data.ctx).toBeDefined()
|
||||
expect(result.data.user).toBeDefined()
|
||||
console.log('✓ API call succeeded after authentication')
|
||||
|
||||
// Save the session for other tests
|
||||
@@ -314,7 +330,7 @@ test.describe('API Mode - 401 Reauth Flow', () => {
|
||||
console.log('✓ Reauth iframe appeared (session older than max_age)')
|
||||
|
||||
// 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.getByRole('button', { name: 'Verify' })).toBeVisible()
|
||||
|
||||
@@ -362,7 +378,7 @@ test.describe('API Mode - 401 Reauth Flow', () => {
|
||||
await waitForAuthIframe(page)
|
||||
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')
|
||||
|
||||
// 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)
|
||||
|
||||
// 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) {
|
||||
// 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
|
||||
await page.waitForTimeout(500)
|
||||
const iframe = page.frameLocator('#auth-iframe')
|
||||
const iframe = page.frameLocator('#paskia-iframe')
|
||||
const headingText = await iframe.locator('h1').textContent()
|
||||
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)
|
||||
|
||||
// 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) {
|
||||
const result = await apiCallPromise
|
||||
@@ -470,7 +486,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
|
||||
}
|
||||
|
||||
await waitForAuthIframe(page)
|
||||
const iframe = page.frameLocator('#auth-iframe')
|
||||
const iframe = page.frameLocator('#paskia-iframe')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
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.iframe).toBeDefined()
|
||||
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}`)
|
||||
})
|
||||
|
||||
+62
-12
@@ -44,7 +44,7 @@ export interface UserInfo {
|
||||
sign_count: number
|
||||
is_current_session: boolean
|
||||
}>
|
||||
aaguid_info: Record<string, { name: string; icon_light?: string; icon_dark?: string }>
|
||||
aaguid_info: Record<string, { name: string; icon?: string; icon_dark?: string }>
|
||||
sessions: Array<{
|
||||
id: string
|
||||
credential: string
|
||||
@@ -193,7 +193,8 @@ export async function registerPasskey(
|
||||
baseUrl: string,
|
||||
options: { resetToken?: string; displayName?: string } = {}
|
||||
): 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
|
||||
let wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/register`
|
||||
const params: string[] = []
|
||||
@@ -203,6 +204,7 @@ export async function registerPasskey(
|
||||
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
let done = false
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected for registration')
|
||||
@@ -213,15 +215,31 @@ export async function registerPasskey(
|
||||
|
||||
// Check for error response
|
||||
if (data.detail) {
|
||||
done = true
|
||||
ws.close()
|
||||
reject(new Error(data.detail))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is the final success response
|
||||
if (data.session_token) {
|
||||
// Check if this is the final success response (exchange_code flow)
|
||||
if (data.exchange_code) {
|
||||
done = true
|
||||
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
|
||||
}
|
||||
|
||||
@@ -293,12 +311,21 @@ export async function registerPasskey(
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!event.wasClean && event.code !== 1000) {
|
||||
if (!done && !event.wasClean && event.code !== 1000) {
|
||||
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
||||
}
|
||||
}
|
||||
})
|
||||
}, { 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 || '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -309,11 +336,13 @@ export async function authenticatePasskey(
|
||||
page: Page,
|
||||
baseUrl: string
|
||||
): 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`
|
||||
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
let done = false
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected for authentication')
|
||||
@@ -324,15 +353,27 @@ export async function authenticatePasskey(
|
||||
|
||||
// Check for error response
|
||||
if (data.detail) {
|
||||
done = true
|
||||
ws.close()
|
||||
reject(new Error(data.detail))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is the final success response
|
||||
if (data.session_token) {
|
||||
// Check if this is the final success response (exchange_code flow)
|
||||
if (data.exchange_code) {
|
||||
done = true
|
||||
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
|
||||
}
|
||||
|
||||
@@ -395,12 +436,21 @@ export async function authenticatePasskey(
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!event.wasClean && event.code !== 1000) {
|
||||
if (!done && !event.wasClean && event.code !== 1000) {
|
||||
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
||||
}
|
||||
}
|
||||
})
|
||||
}, { 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 || '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -429,7 +479,7 @@ export async function getUserInfo(
|
||||
sessionToken: string
|
||||
): Promise<UserInfo> {
|
||||
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: {
|
||||
'Cookie': `${cookieName}=${sessionToken}`,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawn } from 'child_process'
|
||||
import { execSync, spawn } from 'child_process'
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -31,6 +31,11 @@ export default async function globalSetup() {
|
||||
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...')
|
||||
if (COLLECT_COVERAGE) {
|
||||
console.log(' 📊 Coverage collection enabled for Python backend')
|
||||
@@ -42,16 +47,21 @@ export default async function globalSetup() {
|
||||
const serverArgs = COLLECT_COVERAGE
|
||||
? [
|
||||
'run', 'coverage', 'run', '--parallel-mode',
|
||||
'-m', 'paskia.fastapi', 'localhost:4404',
|
||||
'-m', 'paskia', '-l', 'localhost:4404',
|
||||
'--rp-id', 'localhost'
|
||||
]
|
||||
: [
|
||||
'run', 'paskia', 'localhost:4404',
|
||||
'run', 'paskia', '-l', 'localhost:4404',
|
||||
'--rp-id', 'localhost'
|
||||
]
|
||||
|
||||
// Use a temporary jsonl file for test database
|
||||
const testDbFile = join(testDataDir, 'test-db.jsonl')
|
||||
// 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
|
||||
const serverProcess = spawn('uv', serverArgs, {
|
||||
|
||||
@@ -60,10 +60,10 @@ export default async function globalTeardown() {
|
||||
}
|
||||
|
||||
// Clean up test database
|
||||
const testDbFile = join(testDataDir, 'test-db.jsonl')
|
||||
const testDbFile = join(testDataDir, 'test.paskiadb')
|
||||
if (existsSync(testDbFile)) {
|
||||
console.log(' Removing test database...')
|
||||
rmSync(testDbFile)
|
||||
rmSync(testDbFile, { force: true, recursive: true })
|
||||
}
|
||||
|
||||
// Generate Python coverage report if coverage was collected
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["bun-types"]
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["tests/**/*.ts", "playwright.config.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
+38
-107
@@ -8,26 +8,6 @@
|
||||
:root {
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -47,7 +27,7 @@
|
||||
<div class="section">
|
||||
<h2>API Mode (not leaving the page)</h2>
|
||||
<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?perm=auth:admin')">🛡️ Admin Only</button>
|
||||
<button onclick="logout()">🚪 Logout</button>
|
||||
@@ -65,99 +45,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script type="module">
|
||||
import { apiFetch, apiJson, AuthCancelledError } from '/paskia-js/dist/paskia.js'
|
||||
|
||||
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 });
|
||||
|
||||
// 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' });
|
||||
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) {
|
||||
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.
|
||||
// When Accept: text/html, the server redirects to the login page if needed,
|
||||
// then back to the original URL after authentication.
|
||||
function browserNav(url) {
|
||||
window.browserNav = function(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).');
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
+37
-114
@@ -13,7 +13,8 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
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 ProfileView from '@/components/ProfileView.vue'
|
||||
import HostProfileView from '@/components/HostProfileView.vue'
|
||||
@@ -46,114 +47,50 @@ const isHostMode = computed(() => {
|
||||
const configuredHost = normalizeHost(authHost)
|
||||
return currentHost !== configuredHost
|
||||
})
|
||||
let validationTimer = null
|
||||
let authIframe = null
|
||||
|
||||
function terminateSession() {
|
||||
function onSessionLost(e) {
|
||||
store.userInfo = null
|
||||
viewState.value = 'terminal'
|
||||
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() {
|
||||
viewState.value = 'loading'
|
||||
loadingMessage.value = 'Loading...'
|
||||
try {
|
||||
store.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
|
||||
// apiJson handles 401/403 with auth.iframe automatically:
|
||||
// shows overlay iframe, waits for auth, retries the request.
|
||||
const [validateData, userInfoData] = await Promise.all([
|
||||
apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
|
||||
apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
|
||||
])
|
||||
store.userInfo = userInfoData
|
||||
store.ctx = validateData.ctx
|
||||
updateThemeFromSession(store.userInfo)
|
||||
// Verify that the user UUIDs match between user-info and validate responses
|
||||
if (store.userInfo.user.uuid !== store.ctx.user.uuid) {
|
||||
console.error('User UUID mismatch between user-info and validate responses')
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
viewState.value = 'profile'
|
||||
startSessionValidation()
|
||||
return true
|
||||
} catch {
|
||||
store.userInfo = null
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function showAuthIframe() {
|
||||
// Remove existing iframe if any
|
||||
hideAuthIframe()
|
||||
|
||||
// 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 handleAuthMessage(event) {
|
||||
const data = event.data
|
||||
if (!data?.type) return
|
||||
|
||||
switch (data.type) {
|
||||
case 'auth-success':
|
||||
// Authentication successful - reload user info
|
||||
hideAuthIframe()
|
||||
viewState.value = 'loading'
|
||||
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 terminal state
|
||||
hideAuthIframe()
|
||||
terminateSession()
|
||||
break
|
||||
|
||||
case 'auth-close-request':
|
||||
// Legacy support - treat as back
|
||||
hideAuthIframe()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function validateSession() {
|
||||
try {
|
||||
await apiJson('/auth/api/validate', { method: 'POST' })
|
||||
} catch {
|
||||
stopSessionValidation()
|
||||
terminateSession()
|
||||
}
|
||||
}
|
||||
|
||||
function startSessionValidation() {
|
||||
// Validate session every 2 minutes
|
||||
stopSessionValidation()
|
||||
validationTimer = setInterval(validateSession, 2 * 60 * 1000)
|
||||
}
|
||||
|
||||
function stopSessionValidation() {
|
||||
if (validationTimer) {
|
||||
clearInterval(validationTimer)
|
||||
validationTimer = null
|
||||
} catch (e) {
|
||||
onSessionLost(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Listen for postMessage from auth iframe
|
||||
window.addEventListener('message', handleAuthMessage)
|
||||
|
||||
// Load settings
|
||||
await store.loadSettings()
|
||||
|
||||
@@ -167,21 +104,7 @@ onMounted(async () => {
|
||||
document.title = inHostMode ? `${rpName} · Account summary` : rpName
|
||||
}
|
||||
|
||||
// Try to load user info
|
||||
const success = await loadUserInfo()
|
||||
|
||||
if (!success) {
|
||||
// Need authentication - show login iframe
|
||||
showAuthIframe()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('message', handleAuthMessage)
|
||||
stopSessionValidation()
|
||||
hideAuthIframe()
|
||||
// Load user info (apiJson handles auth iframe if needed)
|
||||
await loadUserInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
+374
-116
@@ -9,10 +9,13 @@ import AccessDenied from '@/components/AccessDenied.vue'
|
||||
import AdminOverview from '@/admin/AdminOverview.vue'
|
||||
import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
|
||||
import AdminUserDetail from '@/admin/AdminUserDetail.vue'
|
||||
import AdminOidcDetail from '@/admin/AdminOidcDetail.vue'
|
||||
import AdminDialogs from '@/admin/AdminDialogs.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
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 { goBack } from '@/utils/helpers'
|
||||
|
||||
@@ -24,9 +27,12 @@ const showBackMessage = ref(false)
|
||||
const error = ref(null)
|
||||
const orgs = ref([])
|
||||
const permissions = ref([])
|
||||
const oidcClients = ref([])
|
||||
const currentOrgId = ref(null) // UUID of selected org for 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 editingOidcClient = ref(null) // OIDC client being edited (with local changes)
|
||||
const authStore = useAuthStore()
|
||||
const addingOrgForPermission = ref(null)
|
||||
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
|
||||
@@ -43,6 +49,7 @@ const breadcrumbsRef = ref(null)
|
||||
const adminOverviewRef = ref(null)
|
||||
const adminOrgDetailRef = ref(null)
|
||||
const adminUserDetailRef = ref(null)
|
||||
const adminOidcDetailRef = ref(null)
|
||||
|
||||
// Check if any modal/dialog is open (blocks arrow key navigation)
|
||||
const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value)
|
||||
@@ -79,11 +86,12 @@ onUnmounted(() => {
|
||||
const permissionSummary = computed(() => {
|
||||
const summary = {}
|
||||
for (const o of orgs.value) {
|
||||
const orgBase = { uuid: o.uuid, display_name: o.display_name }
|
||||
const orgPerms = new Set(o.permissions || [])
|
||||
const orgBase = { uuid: o.uuid, display_name: o.org.display_name }
|
||||
// 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
|
||||
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].orgSet.has(o.uuid)) {
|
||||
summary[pid].orgs.push(orgBase)
|
||||
@@ -92,17 +100,18 @@ const permissionSummary = computed(() => {
|
||||
}
|
||||
|
||||
// Role-based permissions (inheritance) - only count if org can grant them
|
||||
for (const r of o.roles) {
|
||||
for (const pid of r.permissions) {
|
||||
for (const [roleUuid, r] of Object.entries(o.roles || {})) {
|
||||
// r.permissions is dict[UUID, bool]
|
||||
for (const pid of Object.keys(r.permissions || {})) {
|
||||
// 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].orgSet.has(o.uuid)) {
|
||||
summary[pid].orgs.push(orgBase)
|
||||
summary[pid].orgSet.add(o.uuid)
|
||||
}
|
||||
summary[pid].userCount += r.users.length
|
||||
summary[pid].userCount += roleUserCount(o, roleUuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,32 +129,76 @@ function parseHash() {
|
||||
const h = window.location.hash || ''
|
||||
currentOrgId.value = null
|
||||
currentUserId.value = null
|
||||
currentOidcId.value = null
|
||||
editingOidcClient.value = null
|
||||
if (h.startsWith('#org/')) {
|
||||
currentOrgId.value = h.slice(5)
|
||||
} else if (h.startsWith('#user/')) {
|
||||
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() {
|
||||
const data = await apiJson('/auth/api/admin/orgs')
|
||||
orgs.value = data.map(o => {
|
||||
const roles = o.roles.map(r => ({ ...r, org: o.uuid, users: [] }))
|
||||
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r]))
|
||||
for (const u of o.users || []) {
|
||||
if (roleMap[u.role]) roleMap[u.role].users.push(u)
|
||||
}
|
||||
return { ...o, roles }
|
||||
})
|
||||
async function loadAdminData() {
|
||||
const data = await apiJson('/auth/api/admin/info')
|
||||
// Convert dicts to arrays with uuid added
|
||||
orgs.value = Object.entries(data.orgs).map(([uuid, o]) => ({ uuid, ...o }))
|
||||
permissions.value = Object.entries(data.permissions).map(([uuid, p]) => ({ uuid, ...p }))
|
||||
oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c }))
|
||||
}
|
||||
|
||||
async function loadPermissions() {
|
||||
permissions.value = await apiJson('/auth/api/admin/permissions')
|
||||
// Helper to get users for a role as sorted array of [uuid, user]
|
||||
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() {
|
||||
const data = await apiJson('/auth/api/validate', { method: 'POST' })
|
||||
const data = await apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
||||
info.value = data
|
||||
updateThemeFromSession(data.ctx)
|
||||
authenticated.value = true
|
||||
}
|
||||
|
||||
@@ -153,17 +206,34 @@ 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() {
|
||||
loading.value = true
|
||||
loadingMessage.value = 'Loading...'
|
||||
error.value = null
|
||||
try {
|
||||
// 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
|
||||
await loadUserInfo()
|
||||
|
||||
@@ -171,18 +241,12 @@ async function load() {
|
||||
if (!window.location.hash || window.location.hash === '#overview') {
|
||||
currentOrgId.value = orgs.value[0].uuid
|
||||
window.location.hash = `#org/${currentOrgId.value}`
|
||||
authStore.showMessage(`Navigating to ${orgs.value[0].display_name} Administration`, 'info', 3000)
|
||||
} else {
|
||||
parseHash()
|
||||
}
|
||||
} else parseHash()
|
||||
} catch (e) {
|
||||
clearSensitiveState()
|
||||
if (e.name === 'AuthCancelledError') {
|
||||
showBackMessage.value = true
|
||||
} else {
|
||||
error.value = e.message
|
||||
}
|
||||
onSessionLost(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -197,17 +261,17 @@ function editUserName(user) { openDialog('user-update-name', { user, name: user.
|
||||
|
||||
async function performOrgDeletion(orgUuid) {
|
||||
await apiJson(`/auth/api/admin/orgs/${orgUuid}`, { method: 'DELETE' })
|
||||
await Promise.all([loadOrgs(), loadPermissions()])
|
||||
await Promise.all([loadAdminData()])
|
||||
}
|
||||
|
||||
function deleteOrg(org) {
|
||||
const userCount = org.roles.reduce((acc, r) => acc + r.users.length, 0)
|
||||
const userCount = orgUserCount(org)
|
||||
|
||||
if (userCount === 0) {
|
||||
// No users in the organization, safe to delete directly
|
||||
performOrgDeletion(org.uuid)
|
||||
.then(() => {
|
||||
authStore.showMessage(`Organization "${org.display_name}" deleted.`, 'success', 2500)
|
||||
authStore.showMessage(`Organization "${org.org.display_name}" deleted.`, 'success', 2500)
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || 'Failed to delete organization', 'error')
|
||||
@@ -216,50 +280,68 @@ function deleteOrg(org) {
|
||||
}
|
||||
|
||||
// Build detailed breakdown of users by role
|
||||
const roleParts = org.roles
|
||||
.filter(r => r.users.length > 0)
|
||||
.map(r => `${r.users.length} ${r.display_name}`)
|
||||
const roleParts = Object.entries(org.roles)
|
||||
.map(([uuid, r]) => ({ role: r, count: roleUserCount(org, uuid) }))
|
||||
.filter(x => x.count > 0)
|
||||
.map(x => `${x.count} ${x.role.display_name}`)
|
||||
|
||||
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)
|
||||
} })
|
||||
}
|
||||
|
||||
function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
|
||||
|
||||
async function moveUserToRole(org, user, targetRoleDisplayName) {
|
||||
if (user.role === targetRoleDisplayName) return
|
||||
function deleteUser(user, userDetail) {
|
||||
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 {
|
||||
await apiJson(`/auth/api/admin/orgs/${org.uuid}/users/${user.uuid}/role`, {
|
||||
await apiJson(`/auth/api/admin/users/${userUuid}`, { method: 'DELETE' })
|
||||
authStore.showMessage(`User "${userName}" deleted.`, 'success', 2500)
|
||||
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: targetRoleDisplayName }
|
||||
body: { role_uuid: targetRoleUuid }
|
||||
})
|
||||
await loadOrgs()
|
||||
await loadAdminData()
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to update user role')
|
||||
}
|
||||
}
|
||||
|
||||
function onUserDragStart(e, user, org) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: user.uuid, org }))
|
||||
}
|
||||
|
||||
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 !== 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 */ }
|
||||
function moveUserToRoleFromDrag(userUuid, newRoleUuid) {
|
||||
const user = selectedOrg.value?.users?.[userUuid]
|
||||
if (user) moveUserToRole(userUuid, user, newRoleUuid)
|
||||
}
|
||||
|
||||
// Role actions
|
||||
@@ -269,10 +351,10 @@ function updateRole(role) { openDialog('role-update', { role, name: role.display
|
||||
|
||||
function deleteRole(role) {
|
||||
// UI only allows deleting empty roles, so no confirmation needed
|
||||
apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}`, { method: 'DELETE' })
|
||||
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'DELETE' })
|
||||
.then(() => {
|
||||
authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500)
|
||||
loadOrgs()
|
||||
loadAdminData()
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || 'Failed to delete role', 'error')
|
||||
@@ -280,19 +362,22 @@ function deleteRole(role) {
|
||||
}
|
||||
|
||||
async function toggleRolePermission(role, pid, checked) {
|
||||
// Optimistic update
|
||||
const prevPermissions = [...role.permissions]
|
||||
const newPermissions = checked
|
||||
? [...role.permissions, pid]
|
||||
: role.permissions.filter(p => p !== pid)
|
||||
// Optimistic update - role.permissions is dict[UUID, bool]
|
||||
const prevPermissions = { ...role.permissions }
|
||||
const newPermissions = { ...role.permissions }
|
||||
if (checked) {
|
||||
newPermissions[pid] = true
|
||||
} else {
|
||||
delete newPermissions[pid]
|
||||
}
|
||||
role.permissions = newPermissions
|
||||
|
||||
try {
|
||||
const method = checked ? 'POST' : 'DELETE'
|
||||
await apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}/permissions/${pid}`, {
|
||||
await apiJson(`/auth/api/admin/roles/${role.uuid}/permissions/${pid}`, {
|
||||
method
|
||||
})
|
||||
await loadOrgs()
|
||||
await loadAdminData()
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to update role permission')
|
||||
role.permissions = prevPermissions // revert
|
||||
@@ -303,7 +388,7 @@ async function toggleRolePermission(role, pid, checked) {
|
||||
async function performPermissionDeletion(permissionUuid) {
|
||||
const params = new URLSearchParams({ permission_uuid: permissionUuid })
|
||||
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' })
|
||||
await loadPermissions()
|
||||
await loadAdminData()
|
||||
}
|
||||
|
||||
function deletePermission(p) {
|
||||
@@ -312,8 +397,8 @@ function deletePermission(p) {
|
||||
// Count roles that have this permission
|
||||
let roleCount = 0
|
||||
for (const org of orgs.value) {
|
||||
for (const role of org.roles) {
|
||||
if (role.permissions.includes(p.uuid)) {
|
||||
for (const role of Object.values(org.roles)) {
|
||||
if (p.uuid in (role.permissions || {})) {
|
||||
roleCount++
|
||||
}
|
||||
}
|
||||
@@ -341,6 +426,104 @@ function deletePermission(p) {
|
||||
} })
|
||||
}
|
||||
|
||||
// OIDC Client actions
|
||||
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)
|
||||
|
||||
function openOrg(o) {
|
||||
@@ -358,34 +541,41 @@ function openUser(u) {
|
||||
const selectedUser = computed(() => {
|
||||
if (!currentUserId.value) return null
|
||||
for (const o of orgs.value) {
|
||||
for (const r of o.roles) {
|
||||
const u = r.users.find(x => x.uuid === currentUserId.value)
|
||||
if (u) return { ...u, org: o.uuid, role_display_name: r.display_name }
|
||||
const u = o.users[currentUserId.value]
|
||||
if (u) {
|
||||
const role = o.roles[u.role]
|
||||
return { ...u, uuid: currentUserId.value, org: o.uuid, role_display_name: role?.display_name }
|
||||
}
|
||||
}
|
||||
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.
|
||||
const breadcrumbEntries = computed(() => {
|
||||
const entries = [
|
||||
{ label: 'Auth', href: makeUiHref() },
|
||||
{ label: 'Admin', href: adminUiPath() }
|
||||
{ label: 'My Profile', href: makeUiHref() }
|
||||
]
|
||||
// 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.
|
||||
let orgForUser = null
|
||||
if (selectedUser.value) {
|
||||
orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org) || null
|
||||
}
|
||||
const orgToShow = selectedOrg.value || orgForUser
|
||||
if (orgToShow) {
|
||||
entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` })
|
||||
// Add org breadcrumb only if it's not already included in the Admin entry
|
||||
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) {
|
||||
entries.push({ label: selectedUser.value.display_name, href: `#user/${selectedUser.value.uuid}` })
|
||||
@@ -396,7 +586,7 @@ const breadcrumbEntries = computed(() => {
|
||||
watch(selectedUser, async (u) => {
|
||||
if (!u) { userDetail.value = null; return }
|
||||
try {
|
||||
userDetail.value = await apiJson(`/auth/api/admin/orgs/${u.org}/users/${u.uuid}`)
|
||||
userDetail.value = await apiJson(`/auth/api/admin/users/${u.uuid}`)
|
||||
} catch (e) {
|
||||
userDetail.value = { error: e.message }
|
||||
}
|
||||
@@ -408,18 +598,27 @@ function generateUserRegistrationLink(u) {
|
||||
}
|
||||
|
||||
async function toggleOrgPermission(org, permId, checked) {
|
||||
// Build next permission list
|
||||
const has = org.permissions.includes(permId)
|
||||
// org.permissions is dict[UUID, Permission]
|
||||
const has = permId in org.permissions
|
||||
if (checked && has) return
|
||||
if (!checked && !has) return
|
||||
const next = checked ? [...org.permissions, permId] : org.permissions.filter(p => p !== permId)
|
||||
// Optimistic update
|
||||
const prev = [...org.permissions]
|
||||
org.permissions = next
|
||||
const prev = { ...org.permissions }
|
||||
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 {
|
||||
const params = new URLSearchParams({ permission_uuid: permId })
|
||||
await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
|
||||
await loadOrgs()
|
||||
await loadAdminData()
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to update organization permission', 'error')
|
||||
org.permissions = prev // revert
|
||||
@@ -529,18 +728,15 @@ function handlePanelNavigateOut(direction) {
|
||||
}
|
||||
|
||||
async function refreshUserDetail() {
|
||||
await loadOrgs()
|
||||
await loadAdminData()
|
||||
if (selectedUser.value) {
|
||||
try {
|
||||
userDetail.value = await apiJson(`/auth/api/admin/orgs/${selectedUser.value.org}/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') }
|
||||
}
|
||||
}
|
||||
|
||||
async function onUserNameSaved() {
|
||||
await refreshUserDetail()
|
||||
authStore.showMessage('User renamed', 'success', 1500)
|
||||
}
|
||||
|
||||
|
||||
async function submitDialog() {
|
||||
if (!dialog.value.type || dialog.value.busy) return
|
||||
@@ -555,7 +751,7 @@ async function submitDialog() {
|
||||
apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`Organization "${name}" created.`, 'success', 2500)
|
||||
Promise.all([loadOrgs(), loadPermissions()])
|
||||
loadAdminData()
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || 'Failed to create organization', 'error')
|
||||
@@ -569,7 +765,7 @@ async function submitDialog() {
|
||||
apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`Organization renamed to "${name}".`, 'success', 2500)
|
||||
loadOrgs()
|
||||
loadAdminData()
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || 'Failed to update organization', 'error')
|
||||
@@ -583,7 +779,7 @@ async function submitDialog() {
|
||||
apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`Role "${name}" created.`, 'success', 2500)
|
||||
loadOrgs()
|
||||
loadAdminData()
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || 'Failed to create role', 'error')
|
||||
@@ -594,10 +790,10 @@ async function submitDialog() {
|
||||
|
||||
// Close dialog immediately, then perform async operation
|
||||
closeDialog()
|
||||
apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
||||
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
|
||||
loadOrgs()
|
||||
loadAdminData()
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || 'Failed to update role', 'error')
|
||||
@@ -611,7 +807,7 @@ async function submitDialog() {
|
||||
apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`User "${name}" added to ${role.display_name} role.`, 'success', 2500)
|
||||
loadOrgs()
|
||||
loadAdminData()
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || 'Failed to add user', 'error')
|
||||
@@ -622,10 +818,10 @@ async function submitDialog() {
|
||||
|
||||
// Close dialog immediately, then perform async operation
|
||||
closeDialog()
|
||||
apiJson(`/auth/api/admin/orgs/${user.org}/users/${user.uuid}/display-name`, { method: 'PATCH', body: { display_name: name } })
|
||||
apiJson(`/auth/api/admin/users/${user.uuid}/info`, { method: 'PATCH', body: { display_name: name } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
|
||||
onUserNameSaved()
|
||||
refreshUserDetail()
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || 'Failed to update user name', 'error')
|
||||
@@ -657,7 +853,7 @@ async function submitDialog() {
|
||||
apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' })
|
||||
.then(() => {
|
||||
authStore.showMessage(`Permission "${newDisplay}" updated.`, 'success', 2500)
|
||||
loadPermissions()
|
||||
loadAdminData()
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || 'Failed to update permission', 'error')
|
||||
@@ -673,12 +869,58 @@ async function submitDialog() {
|
||||
apiJson('/auth/api/admin/permissions', { method: 'POST', body: { scope, display_name, domain: domain || undefined } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`Permission "${display_name}" created.`, 'success', 2500)
|
||||
loadPermissions()
|
||||
loadAdminData()
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || 'Failed to create permission', 'error')
|
||||
})
|
||||
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') {
|
||||
const action = dialog.value.data.action
|
||||
// Close dialog first, then perform action (errors shown via showMessage)
|
||||
@@ -718,7 +960,6 @@ async function submitDialog() {
|
||||
/>
|
||||
<section v-else-if="authenticated && (isMasterAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
|
||||
<header class="view-header">
|
||||
<h1>{{ pageHeading }}</h1>
|
||||
<Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
||||
</header>
|
||||
|
||||
@@ -726,11 +967,12 @@ async function submitDialog() {
|
||||
<div class="section-body admin-section-body">
|
||||
<div class="admin-panels">
|
||||
<AdminOverview
|
||||
v-if="!selectedUser && !selectedOrg && (isMasterAdmin || isOrgAdmin)"
|
||||
v-if="!selectedUser && !selectedOrg && !currentOidcId && (isMasterAdmin || isOrgAdmin)"
|
||||
ref="adminOverviewRef"
|
||||
:info="info"
|
||||
:orgs="orgs"
|
||||
:permissions="permissions"
|
||||
:oidc-clients="oidcClients"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
:permission-summary="permissionSummary"
|
||||
@create-org="createOrg"
|
||||
@@ -741,6 +983,10 @@ async function submitDialog() {
|
||||
@open-dialog="openDialog"
|
||||
@delete-permission="deletePermission"
|
||||
@rename-permission-display="renamePermissionDisplay"
|
||||
@create-oidc-client="createOidcClient"
|
||||
@open-oidc-client="openOidcClient"
|
||||
@delete-oidc-client="deleteOidcClient"
|
||||
@open-server-config="openServerConfig"
|
||||
@navigate-out="handlePanelNavigateOut"
|
||||
/>
|
||||
|
||||
@@ -754,13 +1000,12 @@ async function submitDialog() {
|
||||
:show-reg-modal="showRegModal"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
@generate-user-registration-link="generateUserRegistrationLink"
|
||||
@go-overview="goOverview"
|
||||
@open-org="openOrg"
|
||||
@on-user-name-saved="onUserNameSaved"
|
||||
@refresh-user-detail="refreshUserDetail"
|
||||
@edit-user-name="editUserName"
|
||||
@close-reg-modal="showRegModal = false"
|
||||
@navigate-out="handlePanelNavigateOut"
|
||||
@delete-user="deleteUser(selectedUser, userDetail)"
|
||||
/>
|
||||
<AdminOrgDetail
|
||||
v-else-if="selectedOrg"
|
||||
@@ -775,10 +1020,23 @@ async function submitDialog() {
|
||||
@create-user-in-role="createUserInRole"
|
||||
@open-user="openUser"
|
||||
@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"
|
||||
@on-role-drop="onRoleDrop"
|
||||
@on-user-drag-start="onUserDragStart"
|
||||
/>
|
||||
|
||||
</div>
|
||||
@@ -792,14 +1050,14 @@ async function submitDialog() {
|
||||
:settings="authStore.settings"
|
||||
@submit-dialog="submitDialog"
|
||||
@close-dialog="closeDialog"
|
||||
@reset-oidc-secret="resetOidcSecret"
|
||||
@create-permission-for-client="createPermissionForClient"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="admin-app"></div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import '@/assets/style.css'
|
||||
import { initThemeFromCache } from '@/utils/theme'
|
||||
initThemeFromCache()
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import '@/assets/style.css'
|
||||
import { initThemeFromCache } from '@/utils/theme'
|
||||
initThemeFromCache()
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
@@ -2,19 +2,23 @@
|
||||
<RestrictedAuth
|
||||
:mode="authMode"
|
||||
:remote-auth-token="remoteAuthToken"
|
||||
:oidc-query-string="oidcQueryString"
|
||||
@authenticated="handleAuthenticated"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
||||
|
||||
// Check if this is a remote auth URL: /auth/{token}
|
||||
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
|
||||
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() {
|
||||
const path = window.location.pathname
|
||||
// Match /auth/{token} where token is a passphrase with dots
|
||||
@@ -30,14 +34,20 @@ function extractRemoteToken() {
|
||||
return null
|
||||
}
|
||||
|
||||
// Detect mode from URL hash fragment
|
||||
const authMode = computed(() => {
|
||||
const params = new URLSearchParams(window.location.hash.slice(1))
|
||||
const mode = params.get('mode')
|
||||
if (mode === 'reauth') return 'reauth'
|
||||
if (mode === 'forbidden') return 'forbidden'
|
||||
return 'login'
|
||||
})
|
||||
// Parse URL hash fragment
|
||||
const hashParams = new URLSearchParams(window.location.hash.slice(1))
|
||||
|
||||
// Determine auth mode based on URL path
|
||||
// - /auth/restricted/oidc: OIDC flow, no session dependency
|
||||
// - /auth/restricted/iframe: iframe embedding, mode from hash params
|
||||
// - 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) {
|
||||
if (window.parent && window.parent !== window) {
|
||||
@@ -46,10 +56,15 @@ function postToParent(message) {
|
||||
}
|
||||
|
||||
function handleAuthenticated(result) {
|
||||
if (result.redirect_url) {
|
||||
// OIDC flow: redirect to client with auth code
|
||||
window.location.href = result.redirect_url
|
||||
return
|
||||
}
|
||||
postToParent({
|
||||
type: 'auth-success',
|
||||
authenticated: true,
|
||||
sessionToken: result.session_token
|
||||
exchangeCode: result.exchange_code
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" style="background: transparent">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import './theme.js'
|
||||
import { createApp } from 'vue'
|
||||
import RestrictedApi from './RestrictedApi.vue'
|
||||
import '@/assets/style.css'
|
||||
import { initKeyboardNavigation } from '@/utils/keynav'
|
||||
|
||||
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 name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<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]">
|
||||
{{ status.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="view-root">
|
||||
<div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;">
|
||||
<header class="view-header" style="text-align: center;">
|
||||
<div class="surface surface--tight reset-container">
|
||||
<header class="view-header center">
|
||||
<h1>🔑 Registration</h1>
|
||||
<p class="view-lede">
|
||||
{{ subtitleMessage }}
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
<section class="section-block" v-else-if="!canRegister">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,7 +59,8 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import passkey from '@/utils/passkey'
|
||||
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({
|
||||
show: false,
|
||||
@@ -80,7 +81,7 @@ const sessionDescriptor = computed(() => tokenInfo.value?.token_type || 'your en
|
||||
const subtitleMessage = computed(() => {
|
||||
if (initializing.value) return 'Preparing your secure enrollment…'
|
||||
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())
|
||||
@@ -117,6 +118,7 @@ async function fetchTokenInfo() {
|
||||
headers: { 'Authorization': `Bearer ${token.value}` },
|
||||
})
|
||||
displayName.value = tokenInfo.value.display_name
|
||||
if (tokenInfo.value.theme) updateThemeFromSession({ user: { theme: tokenInfo.value.theme } })
|
||||
} catch (error) {
|
||||
console.error('Failed to load token info', error)
|
||||
const message = error instanceof ApiError
|
||||
@@ -144,7 +146,7 @@ async function registerPasskey() {
|
||||
}
|
||||
|
||||
try {
|
||||
await setSessionCookie(result)
|
||||
await exchangeCode(result)
|
||||
} catch (error) {
|
||||
loading.value = false
|
||||
const message = error?.message || 'Failed to establish session'
|
||||
@@ -156,15 +158,14 @@ async function registerPasskey() {
|
||||
setTimeout(() => { loading.value = false; goHome() }, 800)
|
||||
}
|
||||
|
||||
async function setSessionCookie(result) {
|
||||
if (!result?.session_token) {
|
||||
throw new Error('Registration response missing session_token')
|
||||
async function exchangeCode(result) {
|
||||
if (!result?.exchange_code) {
|
||||
throw new Error('Registration response missing exchange_code')
|
||||
}
|
||||
return await apiJson('/auth/api/set-session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${result.session_token}`
|
||||
}
|
||||
headers: { 'Authorization': `Bearer ${result.exchange_code}` },
|
||||
timeout: paskiaSettings.auth_ms,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -203,13 +204,14 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button-row.center {
|
||||
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||
.reset-container {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
|
||||
.section-body {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -10,10 +10,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"paskia": "file:../paskia-js",
|
||||
"pinia": "^3.0.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"sirv": "^3.0.2",
|
||||
"vue": "^3.5.17"
|
||||
"uuidv7": "^1.1.0",
|
||||
"vue": "^3.5.17",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import NameEditForm from '@/components/NameEditForm.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const props = defineProps({
|
||||
dialog: Object,
|
||||
@@ -9,10 +10,164 @@ const props = defineProps({
|
||||
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 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>
|
||||
|
||||
<template>
|
||||
@@ -25,6 +180,8 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
||||
<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==='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>
|
||||
</h3>
|
||||
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
||||
@@ -76,19 +233,51 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
||||
<input ref="displayNameInput" v-model="dialog.data.display_name" required />
|
||||
</label>
|
||||
<label>Permission Scope
|
||||
<input v-model="dialog.data.scope" :placeholder="dialog.type === 'perm-create' ? 'yourapp:permission' : dialog.data.permission.scope" 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>
|
||||
<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" placeholder="e.g. app.example.com" data-form-type="other" />
|
||||
<input v-model="dialog.data.domain" data-form-type="other" />
|
||||
</label>
|
||||
<p class="small muted">If set, this permission is effective only on the specified domain, which can be {{ rpId }} or its subdomain.</p>
|
||||
<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 v-else-if="dialog.type==='confirm'">
|
||||
<p>{{ dialog.data.message }}</p>
|
||||
</template>
|
||||
<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
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@@ -100,18 +289,46 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
:disabled="dialog.busy"
|
||||
:disabled="dialog.busy || isValidationInvalid"
|
||||
>
|
||||
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
|
||||
</button>
|
||||
</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>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.error { color: var(--color-danger-text); }
|
||||
.small { font-size: 0.9rem; }
|
||||
.muted { color: var(--color-text-muted); }
|
||||
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
|
||||
.oidc-divider { border: none; border-top: 1px solid var(--color-border); margin: var(--space-sm) 0; }
|
||||
.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>
|
||||
|
||||
@@ -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>
|
||||
import { computed, ref } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -8,7 +10,7 @@ const props = defineProps({
|
||||
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
|
||||
const orgTitleRef = ref(null)
|
||||
@@ -16,7 +18,8 @@ const permMatrixRef = ref(null)
|
||||
const rolesGridRef = ref(null)
|
||||
|
||||
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 nameB = b.display_name.toLowerCase()
|
||||
if (nameA !== nameB) {
|
||||
@@ -28,12 +31,45 @@ const sortedRoles = computed(() => {
|
||||
|
||||
// Get org's grantable permissions as full permission objects (with UUIDs)
|
||||
const orgPermissions = computed(() => {
|
||||
const uuidSet = new Set(props.selectedOrg.permissions || [])
|
||||
// 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))
|
||||
})
|
||||
|
||||
function permissionDisplayName(scope) {
|
||||
return props.permissions.find(p => p.scope === scope)?.display_name || scope
|
||||
// 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) {
|
||||
@@ -83,7 +119,7 @@ function handleMatrixKeydown(event) {
|
||||
|
||||
// Calculate grid dimensions
|
||||
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 currentCol = currentIndex % cols
|
||||
@@ -219,7 +255,7 @@ function handleRoleHeaderKeydown(event, roleIndex) {
|
||||
if (checkboxes?.length) {
|
||||
// Focus the checkbox in the corresponding column
|
||||
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
|
||||
if (checkboxes[targetIndex]) checkboxes[targetIndex].focus()
|
||||
else checkboxes[checkboxes.length - 1].focus()
|
||||
@@ -287,7 +323,7 @@ defineExpose({ focusFirstElement })
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</h2>
|
||||
|
||||
@@ -317,7 +353,7 @@ defineExpose({ focusFirstElement })
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="r.permissions.includes(p.uuid)"
|
||||
:checked="p.uuid in (r.permissions || {})"
|
||||
@change="e => toggleRolePermission(r, p.uuid, e.target.checked)"
|
||||
/>
|
||||
</div>
|
||||
@@ -332,84 +368,87 @@ defineExpose({ focusFirstElement })
|
||||
v-for="(r, roleIndex) in sortedRoles"
|
||||
:key="r.uuid"
|
||||
class="role-column"
|
||||
@dragover="$emit('onRoleDragOver', $event)"
|
||||
@drop="e => $emit('onRoleDrop', e, selectedOrg, r)"
|
||||
>
|
||||
<div class="role-header" @keydown="e => handleRoleHeaderKeydown(e, roleIndex)">
|
||||
<strong class="role-name" :title="r.uuid">
|
||||
<span>{{ r.display_name }}</span>
|
||||
<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>
|
||||
<div class="role-actions">
|
||||
<button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user">➕</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="r.users.length > 0">
|
||||
<ul class="user-list" @keydown="handleUserListKeydown">
|
||||
<li
|
||||
v-for="u in r.users.slice().sort((a, b) => {
|
||||
const nameA = a.display_name.toLowerCase()
|
||||
const nameB = b.display_name.toLowerCase()
|
||||
if (nameA !== nameB) {
|
||||
return nameA.localeCompare(nameB)
|
||||
}
|
||||
return a.uuid.localeCompare(b.uuid)
|
||||
})"
|
||||
:key="u.uuid"
|
||||
class="user-chip"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
@dragstart="e => $emit('onUserDragStart', e, u, selectedOrg.uuid)"
|
||||
@click="$emit('openUser', u)"
|
||||
@keydown.enter="$emit('openUser', u)"
|
||||
:title="u.uuid"
|
||||
>
|
||||
<span class="name">{{ u.display_name }}</span>
|
||||
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<div v-else class="empty-role" @keydown="e => handleEmptyRoleKeydown(e, roleIndex)">
|
||||
<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>
|
||||
<div class="user-list-wrapper">
|
||||
<draggable
|
||||
:list="roleUsers(r.uuid)"
|
||||
group="users"
|
||||
item-key="uuid"
|
||||
tag="ul"
|
||||
class="user-list"
|
||||
@change="evt => onUserChange(evt, r.uuid)"
|
||||
@keydown="handleUserListKeydown"
|
||||
>
|
||||
<template #item="{ element: u }">
|
||||
<li
|
||||
class="user-chip"
|
||||
tabindex="0"
|
||||
@click="$emit('openUser', u)"
|
||||
@keydown.enter="$emit('openUser', u)"
|
||||
:title="u.uuid"
|
||||
>
|
||||
<ProfilePicture
|
||||
class="user-chip-picture"
|
||||
:src="u.avatar_url"
|
||||
:title="u.display_name"
|
||||
width="3.25rem"
|
||||
height="100%"
|
||||
radius="0"
|
||||
fallback-size="1.3rem"
|
||||
/>
|
||||
<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>
|
||||
<p v-if="sortedRoles.length >= 2" class="roles-hint muted">Members can be drag&dropped to different roles.</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card.surface { padding: var(--space-lg); }
|
||||
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); }
|
||||
.org-name { font-size: 1.5rem; 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; }
|
||||
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); font-size: 1.65rem; }
|
||||
.org-name { font-weight: 600; color: var(--color-heading); }
|
||||
.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 .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; gap: var(--space-lg); margin-top: var(--space-lg); }
|
||||
.role-column { flex: 1; min-width: 200px; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--space-md); }
|
||||
.roles-grid { display: flex; flex-wrap: wrap; gap: 0; margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
|
||||
.role-column { flex: 0 0 17em; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; }
|
||||
.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-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); }
|
||||
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); }
|
||||
.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-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; }
|
||||
.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 .meta { font-size: 0.7rem; color: var(--color-text-muted); }
|
||||
.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-picture { align-self: stretch; }
|
||||
.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; }
|
||||
.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) {
|
||||
.roles-grid { flex-direction: column; }
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
info: Object,
|
||||
orgs: Array,
|
||||
permissions: Array,
|
||||
oidcClients: Array,
|
||||
permissionSummary: Object,
|
||||
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
|
||||
const orgSection = ref(null)
|
||||
const orgActionsRef = ref(null)
|
||||
const orgTableRef = ref(null)
|
||||
const permMatrixRef = ref(null)
|
||||
@@ -21,27 +22,56 @@ const permActionsRef = ref(null)
|
||||
const permTableRef = ref(null)
|
||||
|
||||
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)
|
||||
}))
|
||||
|
||||
// Map OIDC client UUIDs to display names for permission domain column
|
||||
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 permissionDisplayName(scope) {
|
||||
return props.permissions.find(p => p.scope === scope)?.display_name || scope
|
||||
}
|
||||
|
||||
function getRoleNames(org) {
|
||||
return org.roles
|
||||
// org.roles is dict[UUID, Role]
|
||||
return Object.values(org.roles)
|
||||
.slice()
|
||||
.sort((a, b) => a.display_name.localeCompare(b.display_name))
|
||||
.map(r => r.display_name)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
function orgUserCount(org) {
|
||||
return Object.keys(org.users).length
|
||||
}
|
||||
|
||||
// Table navigation for both org and permissions tables
|
||||
function handleTableKeydown(event, tableType) {
|
||||
if (props.navigationDisabled) return
|
||||
@@ -265,11 +295,11 @@ defineExpose({ focusFirstElement })
|
||||
<tbody>
|
||||
<tr v-for="o in sortedOrgs" :key="o.uuid">
|
||||
<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="isMasterAdmin || isOrgAdmin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
||||
</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="isMasterAdmin" class="center">
|
||||
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization">❌</button>
|
||||
</td>
|
||||
@@ -291,9 +321,9 @@ defineExpose({ focusFirstElement })
|
||||
v-for="o in sortedOrgs"
|
||||
:key="'head-' + o.uuid"
|
||||
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>
|
||||
|
||||
<template v-for="p in sortedPermissions" :key="p.uuid">
|
||||
@@ -307,7 +337,7 @@ defineExpose({ focusFirstElement })
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="o.permissions.includes(p.uuid)"
|
||||
:checked="p.uuid in o.permissions"
|
||||
@change="e => $emit('toggleOrgPermission', o, p.uuid, e.target.checked)"
|
||||
/>
|
||||
</div>
|
||||
@@ -339,7 +369,7 @@ defineExpose({ focusFirstElement })
|
||||
<span class="id-text">{{ p.scope }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="perm-domain">{{ p.domain || '—' }}</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">
|
||||
<button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission">❌</button>
|
||||
@@ -348,13 +378,67 @@ defineExpose({ focusFirstElement })
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
.permissions-section { margin-bottom: var(--space-xl); }
|
||||
.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:hover { text-decoration: underline; }
|
||||
.org-table .center { width: 6rem; min-width: 6rem; }
|
||||
@@ -363,24 +447,19 @@ defineExpose({ focusFirstElement })
|
||||
.perm-title { font-weight: 600; color: var(--color-heading); }
|
||||
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.perm-domain { color: var(--color-text-muted); 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-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 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); }
|
||||
.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); }
|
||||
.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>
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import SessionList from '@/components/SessionList.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiJson } from '@/utils/api'
|
||||
import { apiJson } from 'paskia'
|
||||
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -17,12 +18,19 @@ const props = defineProps({
|
||||
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 terminatingSessions = ref({})
|
||||
const hoveredCredentialUuid = 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
|
||||
const userInfoRef = ref(null)
|
||||
@@ -43,32 +51,48 @@ function handleEditName() {
|
||||
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) {
|
||||
try {
|
||||
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org}/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
|
||||
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
|
||||
if (data.status === 'ok') {
|
||||
emit('onUserNameSaved') // Reuse to refresh user detail
|
||||
emit('refreshUserDetail')
|
||||
authStore.showMessage('Passkey removed', 'success', 2500)
|
||||
} else {
|
||||
console.error('Failed to delete credential', data)
|
||||
authStore.showMessage(data.detail || 'Failed to remove passkey', 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
authStore.showMessage(err.message || 'Failed to remove passkey', 'error')
|
||||
console.error('Delete credential error', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTerminateSession(session) {
|
||||
const sessionId = session?.id
|
||||
if (!sessionId) return
|
||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
|
||||
const sessionKey = session?.key
|
||||
if (!sessionKey) return
|
||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
|
||||
try {
|
||||
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org}/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.current_session_terminated) {
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
return
|
||||
}
|
||||
emit('refreshUserDetail') // Refresh without showing rename message
|
||||
emit('refreshUserDetail')
|
||||
authStore.showMessage('Session terminated', 'success', 2500)
|
||||
} else {
|
||||
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')
|
||||
} finally {
|
||||
const next = { ...terminatingSessions.value }
|
||||
delete next[sessionId]
|
||||
delete next[sessionKey]
|
||||
terminatingSessions.value = next
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteUser() {
|
||||
emit('deleteUser')
|
||||
}
|
||||
|
||||
// Handle user info section keynav
|
||||
function handleUserInfoKeydown(event) {
|
||||
if (hasActiveModal.value || props.navigationDisabled) return
|
||||
@@ -93,7 +121,7 @@ function handleUserInfoKeydown(event) {
|
||||
event.preventDefault()
|
||||
|
||||
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') {
|
||||
emit('navigateOut', 'up')
|
||||
} else if (direction === 'down') {
|
||||
@@ -115,7 +143,7 @@ function handleRegActionsKeydown(event) {
|
||||
navigateButtonRow(regActionsRef.value, event.target, direction, { itemSelector: 'button' })
|
||||
} else if (direction === 'up') {
|
||||
// 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') {
|
||||
// Move to credential list
|
||||
credentialListRef.value?.$el?.focus()
|
||||
@@ -165,10 +193,22 @@ function handleBackButtonKeydown(event) {
|
||||
|
||||
// Focus helper for external navigation
|
||||
function focusFirstElement() {
|
||||
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
|
||||
focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -176,31 +216,40 @@ defineExpose({ focusFirstElement })
|
||||
<div ref="userInfoRef" @keydown="handleUserInfoKeydown">
|
||||
<UserBasicInfo
|
||||
v-if="userDetail && !userDetail.error"
|
||||
:name="userDetail.display_name || selectedUser.display_name"
|
||||
:visits="userDetail.visits"
|
||||
:created-at="userDetail.created_at"
|
||||
:last-seen="userDetail.last_seen"
|
||||
:name="userDetail.user.display_name || selectedUser.display_name"
|
||||
:avatar-url="userDetail.user.avatar_url"
|
||||
:avatar-render-version="avatarRenderVersion"
|
||||
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"
|
||||
:org-display-name="userDetail.org.display_name"
|
||||
:role-name="userDetail.role"
|
||||
:update-endpoint="`/auth/api/admin/orgs/${selectedUser.org}/users/${selectedUser.uuid}/display-name`"
|
||||
@saved="$emit('onUserNameSaved')"
|
||||
@edit-name="handleEditName"
|
||||
/>
|
||||
:role-name="userDetail.role.display_name"
|
||||
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
|
||||
@avatar-click="openPictureDialog"
|
||||
@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 v-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
|
||||
<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">
|
||||
<div class="section-header">
|
||||
<h2>Registered Passkeys</h2>
|
||||
@@ -208,7 +257,7 @@ defineExpose({ focusFirstElement })
|
||||
<div class="section-body">
|
||||
<CredentialList
|
||||
ref="credentialListRef"
|
||||
:credentials="userDetail.credentials"
|
||||
:credentials="credentials"
|
||||
:aaguid-info="userDetail.aaguid_info"
|
||||
:allow-delete="true"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
@@ -222,7 +271,7 @@ defineExpose({ focusFirstElement })
|
||||
</section>
|
||||
<SessionList
|
||||
ref="sessionListRef"
|
||||
:sessions="userDetail.sessions || []"
|
||||
:sessions="userDetail.sessions || {}"
|
||||
:terminating-sessions="terminatingSessions"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
@@ -238,24 +287,25 @@ defineExpose({ focusFirstElement })
|
||||
</div>
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegModal"
|
||||
:endpoint="`/auth/api/admin/orgs/${selectedUser.org}/users/${selectedUser.uuid}/create-link`"
|
||||
:user-name="userDetail?.display_name || selectedUser.display_name"
|
||||
:endpoint="`/auth/api/admin/users/${selectedUser.uuid}/create-link`"
|
||||
:user-name="userDetail?.user?.display_name || selectedUser.display_name"
|
||||
@close="$emit('closeRegModal')"
|
||||
@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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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; }
|
||||
.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>
|
||||
|
||||
@@ -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 {
|
||||
--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;
|
||||
--color-canvas: #ffffff;
|
||||
--color-surface: #eff6ff;
|
||||
--color-surface-subtle: #dbeafe;
|
||||
--color-border: #2563eb;
|
||||
--color-border-strong: #1e40af;
|
||||
--color-heading: #1e3a8a;
|
||||
--color-text: #1e293b;
|
||||
--color-text-muted: #475569;
|
||||
--color-link: #1d4ed8;
|
||||
--color-link-hover: #1e40af;
|
||||
--color-accent: #2563eb;
|
||||
--color-accent-strong: #1e40af;
|
||||
--color-accent-contrast: #ffffff;
|
||||
--color-success-text: #166534;
|
||||
--color-success-bg: #dcfce7;
|
||||
--color-error-text: #b91c1c;
|
||||
--color-error-bg: #fee2e2;
|
||||
--color-info-text: #1e40af;
|
||||
--color-info-bg: #dbeafe;
|
||||
--color-danger: #dc2626;
|
||||
--shadow-soft: 0 10px 30px rgba(30, 64, 175, 0.15);
|
||||
--radius-none: 0;
|
||||
--color-canvas: white;
|
||||
--color-surface: #def;
|
||||
--color-surface-subtle: #bcf;
|
||||
--color-surface-hover: oklab(0.97 -0.01 -0.02);
|
||||
--color-dialog: oklab(0.96 -0.01 -0.03);
|
||||
--color-border: oklab(0.82 -0.02 -0.06);
|
||||
--color-border-strong: oklab(0.55 -0.05 -0.14);
|
||||
--color-heading: oklab(0.22 -0.03 -0.09);
|
||||
--color-text: oklab(0.2 -0.02 -0.05);
|
||||
--color-text-muted: oklab(0.42 -0.02 -0.06);
|
||||
--color-link: oklab(0.5 -0.06 -0.17);
|
||||
--color-link-hover: oklab(0.45 -0.06 -0.19);
|
||||
--color-accent: oklab(0.55 -0.06 -0.19);
|
||||
--color-accent-strong: #46f;
|
||||
--color-accent-contrast: white;
|
||||
--color-secondary: oklab(0.55 -0.02 -0.05);
|
||||
--color-secondary-strong: oklab(0.45 -0.02 -0.05);
|
||||
--color-success-text: oklab(0.4 -0.12 0.09);
|
||||
--color-success-bg: oklab(0.95 -0.02 0.02);
|
||||
--color-error-text: oklab(0.45 0.18 0.09);
|
||||
--color-error-bg: oklab(0.95 0.03 0.01);
|
||||
--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-md: 6px;
|
||||
--radius-lg: 10px;
|
||||
--space-xxs: 0.25rem;
|
||||
--space-xs: 0.5rem;
|
||||
--space-sm: 0.75rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 1.5rem;
|
||||
--space-xl: 2.25rem;
|
||||
--space-xxl: 3.5rem;
|
||||
--space-3xl: 5rem;
|
||||
--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);
|
||||
--transition-base: 160ms ease;
|
||||
--focus-ring: 0 0 0 2px var(--color-accent);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-canvas: #0f172a;
|
||||
--color-surface: #141b2f;
|
||||
--color-surface-subtle: #1b243b;
|
||||
--color-border: #25304a;
|
||||
--color-border-strong: #3d4d6b;
|
||||
--color-heading: #fff;
|
||||
--color-text: #e2e8f0;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-link: #60a5fa;
|
||||
--color-link-hover: #93c5fd;
|
||||
--color-accent: #60a5fa;
|
||||
--color-accent-strong: #3b82f6;
|
||||
--color-accent-contrast: #0b1120;
|
||||
--color-success-text: #34d399;
|
||||
--color-success-bg: #1a4d2e;
|
||||
--color-error-text: #fca5a5;
|
||||
--color-error-bg: #4a1f1f;
|
||||
--color-info-text: #bae6fd;
|
||||
--color-info-bg: #1e3a5f;
|
||||
--color-danger: #f87171;
|
||||
--shadow-soft: 0 0 0 #000000;
|
||||
}
|
||||
:root.dark {
|
||||
--color-canvas: oklab(0.17 -0.02 -0.05);
|
||||
--color-surface: oklab(0.22 -0.02 -0.05);
|
||||
--color-surface-subtle: oklab(0.25 -0.02 -0.05);
|
||||
--color-surface-hover: oklab(0.28 -0.02 -0.05);
|
||||
--color-dialog: oklab(0.22 -0.02 -0.05);
|
||||
--color-border: oklab(0.3 -0.02 -0.05);
|
||||
--color-border-strong: oklab(0.4 -0.02 -0.05);
|
||||
--color-heading: white;
|
||||
--color-text: oklab(0.9 0.00 -0.01);
|
||||
--color-text-muted: oklab(0.7 -0.01 -0.02);
|
||||
--color-link: oklab(0.7 -0.05 -0.14);
|
||||
--color-link-hover: oklab(0.8 -0.04 -0.11);
|
||||
--color-accent: oklab(0.7 -0.05 -0.14);
|
||||
--color-accent-strong: oklab(0.6 -0.06 -0.17);
|
||||
--color-accent-contrast: oklab(0.12 -0.01 -0.03);
|
||||
--color-secondary: oklab(0.6 -0.02 -0.05);
|
||||
--color-secondary-strong: oklab(0.5 -0.02 -0.05);
|
||||
--color-success-text: oklab(0.75 -0.12 0.09);
|
||||
--color-success-bg: oklab(0.3 -0.07 0.05);
|
||||
--color-error-text: oklab(0.8 0.11 0.05);
|
||||
--color-error-bg: oklab(0.3 0.07 0.03);
|
||||
--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 {
|
||||
color-scheme: light dark;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
height: 100%;
|
||||
@@ -132,6 +153,7 @@ a:focus-visible {
|
||||
}
|
||||
|
||||
.view-root {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -150,6 +172,25 @@ a:focus-visible {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -197,6 +238,7 @@ a:focus-visible {
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-row button {
|
||||
@@ -229,6 +271,7 @@ button {
|
||||
gap: 0.4rem;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: box-shadow var(--transition-base), background var(--transition-base), border-color var(--transition-base);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
@@ -236,49 +279,53 @@ button:disabled {
|
||||
filter: opacity(0.6);
|
||||
}
|
||||
|
||||
output[title="Click to copy"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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);
|
||||
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);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-strong);
|
||||
border-color: var(--color-accent-strong);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
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);
|
||||
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-secondary);
|
||||
color: var(--color-accent-contrast);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
filter: brightness(0.92);
|
||||
.btn-secondary:hover:not(:disabled),
|
||||
.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-primary:focus-visible,
|
||||
.btn-secondary:focus-visible,
|
||||
.btn-danger {
|
||||
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-danger);
|
||||
color: var(--color-accent-contrast);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled),
|
||||
.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="search"],
|
||||
input[type="email"],
|
||||
input[type="tel"],
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
@@ -291,6 +338,18 @@ select {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -331,10 +390,118 @@ th {
|
||||
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 {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -353,13 +520,17 @@ th {
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0 auto;
|
||||
z-index: 1200;
|
||||
z-index: 2000;
|
||||
width: fit-content;
|
||||
min-width: min(520px, calc(100% - 2rem));
|
||||
max-width: calc(100% - 2rem);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.global-status.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.global-status .status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -407,7 +578,7 @@ th {
|
||||
|
||||
.device-dialog,
|
||||
.modal {
|
||||
background: var(--color-surface);
|
||||
background: var(--color-dialog);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
width: min(520px, 100%);
|
||||
@@ -429,8 +600,7 @@ th {
|
||||
|
||||
.qr-code {
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-soft);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.link-container,
|
||||
@@ -487,9 +657,18 @@ th {
|
||||
|
||||
.record-item.is-current,
|
||||
.credential-item.current-session,
|
||||
.session-item.is-current {
|
||||
border-color: var(--color-accent);
|
||||
background-color: var(--color-surface-subtle);
|
||||
}
|
||||
|
||||
.credential-item.is-hovered,
|
||||
.session-item.is-current,
|
||||
.session-item.is-hovered { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
|
||||
.session-item.is-hovered {
|
||||
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,
|
||||
.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: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; }
|
||||
|
||||
|
||||
@@ -644,9 +823,6 @@ th {
|
||||
|
||||
.user-info {
|
||||
display: grid;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
padding: 1.1rem 1.25rem;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
@@ -692,8 +868,8 @@ th {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: transparent;
|
||||
backdrop-filter: blur(.1rem) brightness(0.7);
|
||||
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
||||
backdrop-filter: blur(.2rem) brightness(0.7);
|
||||
-webkit-backdrop-filter: blur(.2rem) brightness(0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -711,7 +887,7 @@ th {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
padding: 2rem;
|
||||
background: var(--color-surface);
|
||||
background: var(--color-dialog);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 20px 60px #1e293b;
|
||||
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 {
|
||||
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-radius: var(--radius-sm);
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
@@ -790,3 +930,28 @@ body.has-backdrop {
|
||||
height: 1.8em;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,8 +148,8 @@ const getCredentialAuthIcon = (credential) => {
|
||||
const info = props.aaguidInfo?.[credential.aaguid]
|
||||
if (!info) return null
|
||||
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
|
||||
return info[iconKey] || null
|
||||
// Fall back to icon if icon_dark is not available
|
||||
return (isDarkMode && info.icon_dark) || info.icon || null
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<h1>{{ headingTitle }}</h1>
|
||||
<p class="view-lede">{{ subheading }}</p>
|
||||
@@ -10,9 +10,12 @@
|
||||
<UserBasicInfo
|
||||
v-if="ctx"
|
||||
:name="ctx.user.display_name"
|
||||
:visits="authStore.userInfo?.visits || 0"
|
||||
:created-at="authStore.userInfo?.created_at"
|
||||
:last-seen="authStore.userInfo?.last_seen"
|
||||
:avatar-url="authStore.userInfo.user.avatar_url"
|
||||
:visits="authStore.userInfo.user.visits"
|
||||
: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"
|
||||
:role-name="roleDisplayName"
|
||||
:can-edit="false"
|
||||
@@ -78,9 +81,9 @@ const currentHost = window.location.host
|
||||
const userInfoSection = ref(null)
|
||||
const buttonRow = ref(null)
|
||||
|
||||
const ctx = computed(() => authStore.userInfo?.ctx || null)
|
||||
const orgDisplayName = computed(() => ctx.value?.org.display_name ?? '')
|
||||
const roleDisplayName = computed(() => ctx.value?.role.display_name ?? '')
|
||||
const ctx = computed(() => authStore.userInfo || null)
|
||||
const orgDisplayName = computed(() => ctx.value?.org?.display_name ?? '')
|
||||
const roleDisplayName = computed(() => ctx.value?.role?.display_name ?? '')
|
||||
|
||||
const headingTitle = computed(() => {
|
||||
const service = authStore.settings?.rp_name
|
||||
@@ -125,12 +128,3 @@ const handleButtonRowKeydown = (event) => {
|
||||
// Down does nothing (no elements below to navigate to)
|
||||
}
|
||||
</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 {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--color-border);
|
||||
border-top: 4px solid var(--color-primary);
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-container p {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<template>
|
||||
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
|
||||
<slot />
|
||||
</dialog>
|
||||
<div class="dialog-overlay" @click="$emit('close')">
|
||||
<div ref="dialog" :class="['modal-panel', panelClass]" @keydown="handleDialogKeydown" @click.stop>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav'
|
||||
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||
|
||||
const props = defineProps({
|
||||
// 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
|
||||
focusIndex: { type: Number, default: -1 },
|
||||
// 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
|
||||
const dialog = ref(null)
|
||||
@@ -76,6 +81,13 @@ const restoreFocus = () => {
|
||||
}
|
||||
|
||||
const handleDialogKeydown = (event) => {
|
||||
// ESC to close (previously handled by <dialog> natively)
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
|
||||
const direction = getDirection(event)
|
||||
if (!direction) return
|
||||
|
||||
@@ -111,11 +123,11 @@ onMounted(() => {
|
||||
// Save currently focused element before modal takes focus
|
||||
previouslyFocusedElement.value = document.activeElement
|
||||
|
||||
// Show the dialog as a modal
|
||||
holdGlobalBackdrop()
|
||||
|
||||
// Focus the most appropriate element
|
||||
nextTick(() => {
|
||||
if (dialog.value) {
|
||||
dialog.value.showModal()
|
||||
|
||||
// Autofocus the most appropriate element:
|
||||
// - For form dialogs (rename, edit): focus first input and select text
|
||||
// - For other dialogs: focus primary button (or fallback)
|
||||
@@ -131,14 +143,16 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
releaseGlobalBackdrop()
|
||||
// Restore focus when modal closes
|
||||
restoreFocus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
dialog {
|
||||
background: var(--color-surface);
|
||||
.modal-panel {
|
||||
background: var(--color-dialog);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
@@ -147,65 +161,36 @@ dialog {
|
||||
width: min(500px, 90vw);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: transparent;
|
||||
backdrop-filter: blur(.1rem) brightness(0.7);
|
||||
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
||||
}
|
||||
|
||||
dialog :deep(.modal-title),
|
||||
dialog :deep(h3) {
|
||||
.modal-panel :deep(.modal-title),
|
||||
.modal-panel :deep(h3) {
|
||||
margin: 0 0 var(--space-md);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
dialog :deep(form) {
|
||||
.modal-panel :deep(form) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
dialog :deep(.modal-form) {
|
||||
.modal-panel :deep(.modal-form) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
dialog :deep(.modal-form label) {
|
||||
.modal-panel :deep(.modal-form label) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
dialog :deep(.modal-form input),
|
||||
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) {
|
||||
.modal-panel :deep(.modal-actions) {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
|
||||
@@ -84,12 +84,4 @@ function handleCancel() {
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-danger-text);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</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>
|
||||
<section class="view-root" data-view="profile">
|
||||
<header class="view-header">
|
||||
<h1>User Profile</h1>
|
||||
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
||||
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p>
|
||||
</header>
|
||||
<section class="view-root view-root--profile" data-view="profile">
|
||||
<div class="view-header-wrapper">
|
||||
<div class="theme-toggle">
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
<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
|
||||
v-if="authStore.userInfo?.ctx"
|
||||
v-if="authStore.userInfo?.user"
|
||||
ref="userBasicInfo"
|
||||
:name="authStore.userInfo.ctx.user.display_name"
|
||||
:visits="authStore.userInfo.visits"
|
||||
:created-at="authStore.userInfo.created_at"
|
||||
:last-seen="authStore.userInfo.last_seen"
|
||||
:name="authStore.userInfo.user.display_name"
|
||||
: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"
|
||||
:last-seen="authStore.userInfo.user.last_seen"
|
||||
: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()"
|
||||
@edit-name="openNameDialog"
|
||||
@avatar-click="openAvatarDialog"
|
||||
@edit="openEditDialog"
|
||||
@keydown="handleUserInfoKeydown"
|
||||
>
|
||||
<div class="remote-auth-inline">
|
||||
@@ -31,11 +44,11 @@
|
||||
@device-info-visible="showDeviceInfo = $event"
|
||||
/>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section class="section-block">
|
||||
<section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
|
||||
<div class="section-header">
|
||||
<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>
|
||||
@@ -43,8 +56,8 @@
|
||||
<div class="section-body">
|
||||
<CredentialList
|
||||
ref="credentialList"
|
||||
:credentials="authStore.userInfo?.credentials || []"
|
||||
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
|
||||
:credentials="credentials"
|
||||
:aaguid-info="authStore.userInfo.aaguid_info"
|
||||
:loading="authStore.isLoading"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
:hovered-session-credential-uuid="hoveredSession?.credential"
|
||||
@@ -67,25 +80,14 @@
|
||||
:terminating-sessions="terminatingSessions"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
:section-class="useWideLayout ? '' : 'section-block--constrained'"
|
||||
@terminate="terminateSession"
|
||||
@session-hover="hoveredSession = $event"
|
||||
@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."
|
||||
/>
|
||||
|
||||
<Modal v-if="showNameDialog" @close="showNameDialog = false">
|
||||
<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">
|
||||
<section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
|
||||
<div class="button-row" ref="logoutButtons">
|
||||
<button
|
||||
type="button"
|
||||
@@ -101,9 +103,47 @@
|
||||
<button @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">All</button>
|
||||
</template>
|
||||
</div>
|
||||
<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 }}. You'll need to log in again with your passkey afterwards.</p>
|
||||
<div class="logout-footer">
|
||||
<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>
|
||||
|
||||
<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
|
||||
v-if="showRegLink"
|
||||
endpoint="/auth/api/user/create-link"
|
||||
@@ -117,9 +157,10 @@
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.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 Modal from '@/components/Modal.vue'
|
||||
import NameEditForm from '@/components/NameEditForm.vue'
|
||||
import SessionList from '@/components/SessionList.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import RemoteAuthPermit from '@/components/RemoteAuthPermit.vue'
|
||||
@@ -127,15 +168,21 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
||||
import passkey from '@/utils/passkey'
|
||||
import { goBack } from '@/utils/helpers'
|
||||
import { apiJson } from '@/utils/api'
|
||||
import { apiJson } from 'paskia'
|
||||
import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@/utils/keynav'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const updateInterval = ref(null)
|
||||
const showNameDialog = ref(false)
|
||||
const showEditDialog = ref(false)
|
||||
const showAvatarDialog = 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 editError = ref('')
|
||||
const hoveredCredentialUuid = ref(null)
|
||||
const hoveredSession = ref(null)
|
||||
const showDeviceInfo = ref(false)
|
||||
@@ -145,19 +192,49 @@ const credentialButtons = ref(null)
|
||||
const sessionList = ref(null)
|
||||
const logoutButtons = ref(null)
|
||||
const breadcrumbs = ref(null)
|
||||
const userBasicInfo = ref(null)
|
||||
const userInfoSection = ref(null)
|
||||
|
||||
// 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?.ctx.user.display_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(() => {
|
||||
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 () => {
|
||||
try {
|
||||
@@ -206,7 +283,7 @@ const handleBreadcrumbKeydown = (event) => {
|
||||
if (direction === 'down') {
|
||||
event.preventDefault()
|
||||
// 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
|
||||
}
|
||||
@@ -218,7 +295,7 @@ const handleUserInfoKeydown = (event) => {
|
||||
if (!direction) return
|
||||
|
||||
event.preventDefault()
|
||||
const itemSelector = '.mini-btn, .pairing-input'
|
||||
const itemSelector = '.user-picture-btn, .mini-btn, .pairing-input'
|
||||
|
||||
if (direction === 'left' || direction === 'right') {
|
||||
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
|
||||
@@ -239,7 +316,7 @@ const handleCredentialNavigateOut = (direction) => {
|
||||
focusPreferredButton(credentialButtons.value)
|
||||
} else if (direction === 'up' || direction === 'left') {
|
||||
// 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' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,59 +378,96 @@ const handleDelete = async (credential) => {
|
||||
}
|
||||
|
||||
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 currentSession = sessions.value.find(session => session.is_current)
|
||||
const currentSession = Object.values(sessions.value).find(session => session.is_current)
|
||||
return currentSession?.host || 'this host'
|
||||
})
|
||||
const terminatingSessions = ref({})
|
||||
|
||||
const terminateSession = async (session) => {
|
||||
const sessionId = session?.id
|
||||
if (!sessionId) return
|
||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
|
||||
try { await authStore.terminateSession(sessionId) }
|
||||
const sessionKey = session?.key
|
||||
if (!sessionKey) return
|
||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
|
||||
try { await authStore.terminateSession(sessionKey) }
|
||||
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
|
||||
finally {
|
||||
const next = { ...terminatingSessions.value }
|
||||
delete next[sessionId]
|
||||
delete next[sessionKey]
|
||||
terminatingSessions.value = next
|
||||
}
|
||||
}
|
||||
|
||||
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
|
||||
const logout = async () => { await authStore.logout() }
|
||||
const openNameDialog = () => { newName.value = authStore.userInfo?.ctx.user.display_name ?? ''; showNameDialog.value = true }
|
||||
const openEditDialog = () => { showEditDialog.value = true }
|
||||
const isAdmin = computed(() => {
|
||||
const perms = authStore.userInfo?.ctx.permissions
|
||||
const perms = Object.values(authStore.userInfo.permissions).map(p => p.scope)
|
||||
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
|
||||
})
|
||||
const hasMultipleSessions = computed(() => sessions.value.length > 1)
|
||||
const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
|
||||
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 () => {
|
||||
const name = newName.value.trim()
|
||||
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
|
||||
// Check if passkeys exceed 8
|
||||
const hasManyCredentials = credentials.value.length > 8
|
||||
|
||||
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 {
|
||||
editError.value = ''
|
||||
saving.value = true
|
||||
await apiJson('/auth/api/user/display-name', { method: 'PATCH', body: { display_name: name } })
|
||||
showNameDialog.value = false
|
||||
await authStore.loadUserInfo()
|
||||
authStore.showMessage('Name updated successfully!', 'success', 3000)
|
||||
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
|
||||
finally { saving.value = false }
|
||||
let changed = false
|
||||
const body = {}
|
||||
if (name !== user.display_name) body.display_name = name
|
||||
if (emailVal !== (user.email || null)) body.email = emailVal
|
||||
if (usernameVal !== (user.preferred_username || null)) body.preferred_username = usernameVal
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; }
|
||||
.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; }
|
||||
.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-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
|
||||
.remote-auth-description {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.remote-auth-description { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||
.theme-toggle { position: absolute; top: var(--layout-padding); right: var(--layout-padding); }
|
||||
.profile-edit-form { display: flex; flex-direction: column; gap: var(--space-md); }
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
<template>
|
||||
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
|
||||
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
|
||||
<div v-if="linkUrl" class="dialog-overlay" @click="$emit('close')">
|
||||
<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">
|
||||
<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>
|
||||
<button class="icon-btn" @click="$emit('close')" aria-label="Close" tabindex="-1">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="device-link-section">
|
||||
<p class="reg-help">
|
||||
Scan this QR code on the new device, or copy the link and open it there.
|
||||
{{ helpText }}
|
||||
</p>
|
||||
|
||||
<QRCodeDisplay
|
||||
@@ -28,16 +29,18 @@
|
||||
<div class="reg-actions" ref="actionsRow" @keydown="handleActionsKeydown">
|
||||
<button class="btn-secondary" @click="$emit('close')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from '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 { getDirection } from '@/utils/keynav'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: { type: String, required: true },
|
||||
@@ -46,34 +49,50 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close', 'copied'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const dialog = ref(null)
|
||||
const linkUrl = ref(null)
|
||||
const expiresAt = ref(null)
|
||||
const tokenType = ref(null)
|
||||
const actionsRow = ref(null)
|
||||
// Store the element that had focus before modal opened
|
||||
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() {
|
||||
try {
|
||||
const data = await apiJson(props.endpoint, { method: 'POST' })
|
||||
if (data.url) {
|
||||
linkUrl.value = data.url
|
||||
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()
|
||||
if (dialog.value) {
|
||||
dialog.value.showModal()
|
||||
|
||||
// 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()
|
||||
}
|
||||
const actions = actionsRow.value
|
||||
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
|
||||
target?.focus()
|
||||
} else {
|
||||
emit('close')
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (!(e instanceof AuthCancelledError)) {
|
||||
authStore.showMessage(e.message || 'Failed to generate link', 'error')
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
@@ -83,7 +102,12 @@ function onCopied() {
|
||||
}
|
||||
|
||||
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
|
||||
const direction = getDirection(event)
|
||||
if (!direction) return
|
||||
@@ -129,6 +153,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (linkUrl.value) releaseGlobalBackdrop()
|
||||
// Restore focus when modal closes
|
||||
const prev = previouslyFocusedElement.value
|
||||
if (prev && document.body.contains(prev) && !prev.disabled) {
|
||||
@@ -138,23 +163,6 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<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:hover { opacity: 1; }
|
||||
.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) -->
|
||||
<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-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
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
:disabled="loading"
|
||||
@click="deny"
|
||||
style="flex: 1;"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
@@ -72,7 +71,6 @@
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="btn-primary"
|
||||
style="flex: 1;"
|
||||
>
|
||||
{{ loading ? 'Authenticating…' : 'Authorize' }}
|
||||
</button>
|
||||
@@ -773,7 +771,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -921,10 +918,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.device-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -945,9 +938,18 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 0;
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
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>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="error-section">
|
||||
<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>
|
||||
|
||||
<!-- Connecting phase -->
|
||||
@@ -163,6 +163,9 @@ async function startRemoteAuth() {
|
||||
|
||||
// PoW challenge
|
||||
const powChallenge = await ws.receive_json()
|
||||
if (powChallenge.status) {
|
||||
throw new Error(powChallenge.detail || `Failed to connect: ${powChallenge.status}`)
|
||||
}
|
||||
if (powChallenge.pow) {
|
||||
const challenge = b64dec(powChallenge.pow.challenge)
|
||||
const nonces = await solvePoW(challenge, powChallenge.pow.work)
|
||||
@@ -196,7 +199,7 @@ async function startRemoteAuth() {
|
||||
} else if (msg.status === 'authenticated') {
|
||||
// Success
|
||||
completed.value = true
|
||||
emit('authenticated', { session_token: msg.session_token })
|
||||
emit('authenticated', { exchange_code: msg.exchange_code })
|
||||
break
|
||||
} else if (msg.status === 'denied') {
|
||||
// Explicitly denied by the authenticating device
|
||||
@@ -293,10 +296,6 @@ defineExpose({ retry, cancel })
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.auth-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -499,6 +498,10 @@ defineExpose({ retry, cancel })
|
||||
color: var(--color-error, #ef4444);
|
||||
}
|
||||
|
||||
.error-section button {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.auth-content {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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]">
|
||||
{{ status.message }}
|
||||
</div>
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="section-body center">
|
||||
<!-- Local passkey authentication 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"
|
||||
:loading="loading"
|
||||
:can-authenticate="canAuthenticate"
|
||||
@@ -58,15 +58,20 @@
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import passkey from '@/utils/passkey'
|
||||
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 { focusDialogButton } from '@/utils/keynav'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'login',
|
||||
validator: (value) => ['login', 'reauth', 'forbidden'].includes(value)
|
||||
validator: (value) => ['login', 'reauth', 'forbidden', 'oidc'].includes(value)
|
||||
},
|
||||
oidcQueryString: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -142,7 +147,8 @@ async function fetchSettings() {
|
||||
|
||||
async function validateSession() {
|
||||
try {
|
||||
session.value = await fetchJson('/auth/api/validate', { 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') {
|
||||
currentView.value = 'forbidden'
|
||||
emit('forbidden', session.value)
|
||||
@@ -163,7 +169,7 @@ async function authenticateUser() {
|
||||
loading.value = true
|
||||
showMessage('Starting authentication…', 'info')
|
||||
let result
|
||||
try { result = await passkey.authenticate() } catch (error) {
|
||||
try { result = await passkey.authenticate(props.oidcQueryString) } catch (error) {
|
||||
loading.value = false
|
||||
const message = error?.message || 'Passkey authentication cancelled'
|
||||
const cancelled = message === 'Passkey authentication cancelled'
|
||||
@@ -171,7 +177,13 @@ async function authenticateUser() {
|
||||
emit('auth-error', { message, cancelled })
|
||||
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
|
||||
const message = error?.message || 'Failed to establish session'
|
||||
showMessage(message, 'error', 4000)
|
||||
@@ -186,7 +198,7 @@ async function logoutUser() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await fetchJson('/auth/api/logout', { method: 'POST' })
|
||||
await fetchJson('/auth/api/logout', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
||||
session.value = null
|
||||
currentView.value = 'login'
|
||||
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
|
||||
@@ -202,13 +214,13 @@ function openProfile() {
|
||||
if (profileWindow) profileWindow.focus()
|
||||
}
|
||||
|
||||
async function setSessionCookie(result) {
|
||||
if (!result?.session_token) {
|
||||
console.error('setSessionCookie called with missing session_token:', result)
|
||||
throw new Error('Authentication response missing session_token')
|
||||
async function exchangeCode(result) {
|
||||
if (!result?.exchange_code) {
|
||||
console.error('exchangeCode called with missing exchange_code:', result)
|
||||
throw new Error('Authentication response missing exchange_code')
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -223,7 +235,7 @@ function switchToLocal() {
|
||||
async function handleRemoteAuthenticated(result) {
|
||||
showMessage('Authenticated from another device!', 'success', 2000)
|
||||
try {
|
||||
await setSessionCookie(result)
|
||||
await exchangeCode(result)
|
||||
} catch (error) {
|
||||
const message = error?.message || 'Failed to establish session'
|
||||
showMessage(message, 'error', 4000)
|
||||
@@ -265,7 +277,12 @@ watch(initializing, (newVal) => {
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchSettings()
|
||||
await validateSession()
|
||||
// OIDC mode doesn't depend on session state - skip validation
|
||||
if (props.mode !== 'oidc') {
|
||||
await validateSession()
|
||||
} else {
|
||||
currentView.value = 'login'
|
||||
}
|
||||
initializing.value = false
|
||||
|
||||
// Add click handler for inline links
|
||||
@@ -284,7 +301,6 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<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); }
|
||||
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||
.surface.surface--tight {
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
<template>
|
||||
<section class="section-block" data-component="session-list-section">
|
||||
<section :class="['section-block', sectionClass]" data-component="session-list-section">
|
||||
<div class="section-header">
|
||||
<h2>Active Sessions</h2>
|
||||
<p class="section-description">{{ sectionDescription }}</p>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div>
|
||||
<template v-if="Array.isArray(sessions) && sessions.length">
|
||||
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, host)">
|
||||
<template v-if="sessionsArray.length">
|
||||
<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-icon">🌐</span>
|
||||
<a v-if="host" :href="hostUrl(host)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ host }}</a>
|
||||
<span class="session-group-icon">{{ group.isOIDC ? '🪪' : '🌐' }}</span>
|
||||
<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>
|
||||
</span>
|
||||
<div class="session-list">
|
||||
<div
|
||||
v-for="session in group.sessions"
|
||||
:key="session.id"
|
||||
:key="session.key"
|
||||
:class="['session-item', {
|
||||
'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
|
||||
}]"
|
||||
tabindex="-1"
|
||||
@@ -30,17 +31,17 @@
|
||||
@keydown="handleItemKeydown($event, session)"
|
||||
>
|
||||
<div class="item-top">
|
||||
<h4 class="item-title">{{ session.user_agent }}</h4>
|
||||
<h4 class="item-title">{{ session.user_agent || '—' }}</h4>
|
||||
<div class="item-actions">
|
||||
<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" class="badge badge-current">Linked</span>
|
||||
<span v-else-if="!hoveredCredentialUuid && isSameHost(session.ip)" class="badge">Same IP</span>
|
||||
<button
|
||||
@click="$emit('terminate', session)"
|
||||
class="btn-card-delete"
|
||||
:disabled="isTerminating(session.id)"
|
||||
:title="isTerminating(session.id) ? 'Terminating...' : 'Terminate session'"
|
||||
:disabled="isTerminating(session.key)"
|
||||
:title="isTerminating(session.key) ? 'Terminating...' : 'Terminate session'"
|
||||
tabindex="-1"
|
||||
>❌</button>
|
||||
</div>
|
||||
@@ -69,12 +70,13 @@ import { hostIP } from '@/utils/helpers'
|
||||
import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav'
|
||||
|
||||
const props = defineProps({
|
||||
sessions: { type: Array, default: () => [] },
|
||||
sessions: { type: Object, default: () => ({}) },
|
||||
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." },
|
||||
terminatingSessions: { type: Object, default: () => ({}) },
|
||||
hoveredCredentialUuid: { type: String, default: null },
|
||||
navigationDisabled: { type: Boolean, default: false },
|
||||
sectionClass: { type: String, default: '' },
|
||||
})
|
||||
|
||||
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 group = event.currentTarget
|
||||
@@ -149,7 +151,7 @@ const handleGroupKeydown = (event, host) => {
|
||||
const handleItemKeydown = (event, session) => {
|
||||
// Handle delete (always allowed even with modal)
|
||||
handleDeleteKey(event, () => {
|
||||
if (!isTerminating(session.id)) emit('terminate', session)
|
||||
if (!isTerminating(session.key)) emit('terminate', session)
|
||||
})
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
@@ -208,9 +210,14 @@ const copyIp = async (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(() => {
|
||||
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
|
||||
})
|
||||
|
||||
@@ -218,27 +225,20 @@ const isSameHost = ip => currentHostIP.value && hostIP(ip) === currentHostIP.val
|
||||
|
||||
const groupedSessions = computed(() => {
|
||||
const groups = {}
|
||||
for (const session of props.sessions) {
|
||||
const host = session.host || ''
|
||||
if (!groups[host]) {
|
||||
groups[host] = { sessions: [], isCurrentSite: false }
|
||||
}
|
||||
groups[host].sessions.push(session)
|
||||
if (session.is_current_host) {
|
||||
groups[host].isCurrentSite = true
|
||||
for (const session of sessionsArray.value) {
|
||||
const groupKey = session.client || session.host || ''
|
||||
if (!groups[groupKey]) {
|
||||
groups[groupKey] = { sessions: [], isCurrentSite: false, isOIDC: !!session.client, displayName: session.client_name || groupKey }
|
||||
}
|
||||
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 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)
|
||||
for (const groupKey in groups) groups[groupKey].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
||||
const sortedHosts = Object.keys(groups).sort(collator.compare)
|
||||
const sortedGroups = {}
|
||||
for (const host of sortedHosts) {
|
||||
sortedGroups[host] = groups[host]
|
||||
}
|
||||
return sortedGroups
|
||||
const sorted = Object.entries(groups).sort(([, a], [, b]) => {
|
||||
if (a.isOIDC !== b.isOIDC) return a.isOIDC ? 1 : -1
|
||||
return collator.compare(a.displayName, b.displayName) || collator.compare(a.sessions[0]?.client || '', b.sessions[0]?.client || '')
|
||||
})
|
||||
return Object.fromEntries(sorted)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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]">
|
||||
{{ authStore.status.message }}
|
||||
</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>
|
||||
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
|
||||
<h3 class="user-name-heading">
|
||||
<span class="icon">👤</span>
|
||||
<span class="user-name-row">
|
||||
<span class="display-name" :title="name">{{ name }}</span>
|
||||
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('editName')" title="Edit name">✏️</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="user-details">
|
||||
<span class="date-label"><strong>Visits:</strong></span>
|
||||
<span class="date-value">{{ visits || 0 }}</span>
|
||||
<span class="date-label"><strong>Registered:</strong></span>
|
||||
<span class="date-value">{{ formatDate(createdAt) }}</span>
|
||||
<span class="date-label"><strong>Last seen:</strong></span>
|
||||
<span class="date-value">{{ formatDate(lastSeen) }}</span>
|
||||
<div class="user-info-content">
|
||||
<ProfilePicture
|
||||
:src="avatarUrl"
|
||||
:render-version="avatarRenderVersion"
|
||||
:clickable="avatarClickable"
|
||||
:loading="loading"
|
||||
:title="avatarClickable ? 'Change profile picture' : ''"
|
||||
width="5.25rem"
|
||||
height="5.25rem"
|
||||
radius="var(--radius-sm)"
|
||||
fallback-size="2.8em"
|
||||
class="user-picture"
|
||||
:class="avatarClickable ? 'user-picture-btn' : ''"
|
||||
@click="emit('avatar-click')"
|
||||
/>
|
||||
<h3 class="user-name-heading">
|
||||
<span class="user-name-row">
|
||||
<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 v-if="$slots.default" class="user-info-extra">
|
||||
<slot></slot>
|
||||
@@ -26,80 +52,85 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { computed } from 'vue'
|
||||
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
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 },
|
||||
createdAt: { type: [String, Number, Date], default: null },
|
||||
lastSeen: { type: [String, Number, Date], default: null },
|
||||
updateEndpoint: { type: String, default: null },
|
||||
canEdit: { type: Boolean, default: true },
|
||||
avatarClickable: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
orgDisplayName: { type: String, default: '' },
|
||||
roleName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['saved', 'editName'])
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const emit = defineEmits(['saved', 'edit', 'avatar-click'])
|
||||
const userLoaded = computed(() => !!props.name)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-info.has-extra {
|
||||
grid-template-columns: auto 1fr 2fr;
|
||||
grid-template-columns: minmax(0, 1fr) 14rem;
|
||||
grid-template-areas:
|
||||
"heading heading extra"
|
||||
"org org extra"
|
||||
"label1 value1 extra"
|
||||
"label2 value2 extra"
|
||||
"label3 value3 extra";
|
||||
"content extra";
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.user-info:not(.has-extra) {
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"heading heading"
|
||||
"org org"
|
||||
"label1 value1"
|
||||
"label2 value2"
|
||||
"label3 value3";
|
||||
"content";
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.user-info.has-extra {
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"heading heading"
|
||||
"org org"
|
||||
"label1 value1"
|
||||
"label2 value2"
|
||||
"label3 value3"
|
||||
"extra extra";
|
||||
"content"
|
||||
"extra";
|
||||
}
|
||||
}
|
||||
|
||||
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
|
||||
.org-role-sub { grid-area: org; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
|
||||
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; }
|
||||
.info-label:nth-of-type(1) { grid-area: label1; }
|
||||
.info-value:nth-of-type(2) { grid-area: value1; }
|
||||
.info-label:nth-of-type(3) { grid-area: label2; }
|
||||
.info-value:nth-of-type(4) { grid-area: value2; }
|
||||
.info-label:nth-of-type(5) { grid-area: label3; }
|
||||
.info-value:nth-of-type(6) { grid-area: value3; }
|
||||
.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; }
|
||||
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.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); }
|
||||
.user-name-heading .name-input { width: auto; }
|
||||
.name-input:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); }
|
||||
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; }
|
||||
.user-info-content {
|
||||
grid-area: content;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"picture heading fields"
|
||||
"picture org fields"
|
||||
"picture info info";
|
||||
gap: 0 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.user-picture) { grid-area: picture; align-self: stretch; }
|
||||
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
|
||||
.org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
|
||||
.org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.role-line { font-size: .65rem; color: var(--color-text-muted); line-height: 1.1; }
|
||||
.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:active:not(:disabled) { transform: translateY(1px); }
|
||||
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { register, authenticate } from '@/utils/passkey'
|
||||
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', {
|
||||
state: () => ({
|
||||
// Auth State
|
||||
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
|
||||
ctx: null, // Session context from validate
|
||||
isLoading: false,
|
||||
|
||||
// Settings
|
||||
@@ -48,6 +50,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
return await apiJson('/auth/api/set-session', {
|
||||
method: 'POST',
|
||||
headers: {'Authorization': `Bearer ${result.session_token}`},
|
||||
timeout: paskiaSettings.auth_ms,
|
||||
})
|
||||
},
|
||||
async register() {
|
||||
@@ -85,7 +88,8 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
async loadUserInfo() {
|
||||
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)
|
||||
} catch (error) {
|
||||
// Suppress toast for 401/403 errors - the auth iframe will handle these
|
||||
@@ -101,9 +105,9 @@ export const useAuthStore = defineStore('auth', {
|
||||
await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
|
||||
await this.loadUserInfo()
|
||||
},
|
||||
async terminateSession(sessionId) {
|
||||
async terminateSession(sessionKey) {
|
||||
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) {
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
@@ -118,7 +122,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
await apiJson('/auth/api/logout', {method: 'POST'})
|
||||
await apiJson('/auth/api/logout', {method: 'POST', timeout: paskiaSettings.auth_ms})
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
@@ -131,7 +135,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
async logoutEverywhere() {
|
||||
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()
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,405 +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')
|
||||
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 = {
|
||||
...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 aWebSocket from '@/utils/awaitable-websocket'
|
||||
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
|
||||
// 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() {
|
||||
const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate'))
|
||||
export async function authenticate(queryString = null) {
|
||||
// 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 {
|
||||
let res = await ws.receive_json()
|
||||
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)
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* Environment variables (with defaults):
|
||||
* FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying
|
||||
* Options:
|
||||
* paths - Array of paths to proxy (default: ["/api"])
|
||||
*/
|
||||
|
||||
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:5180"
|
||||
|
||||
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) {
|
||||
@@ -23,7 +24,7 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
}
|
||||
|
||||
return {
|
||||
name: "fastapi-vite",
|
||||
name: "vite-plugin-fastapi-paskia",
|
||||
config: () => ({
|
||||
server: { proxy },
|
||||
build: {
|
||||
|
||||
+16
-1
@@ -16,6 +16,7 @@ export default defineConfig(({ command }) => ({
|
||||
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
|
||||
@@ -46,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',
|
||||
configureServer(server) {
|
||||
@@ -90,7 +103,9 @@ export default defineConfig(({ command }) => ({
|
||||
}
|
||||
].filter(Boolean),
|
||||
resolve: {
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
}
|
||||
},
|
||||
base: '/',
|
||||
server: {
|
||||
|
||||
@@ -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()
|
||||
@@ -30,4 +30,16 @@ def filter(aaguids: Iterable[UUID]) -> dict[str, dict]:
|
||||
Dictionary mapping AAGUID string to authenticator information for only
|
||||
the AAGUIDs that the user has and that we have data for
|
||||
"""
|
||||
return {(s := str(a)): AAGUID[s] for a in aaguids if (s := str(a)) 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)
|
||||
@@ -14,6 +14,7 @@ from uuid import UUID
|
||||
|
||||
from paskia import db
|
||||
from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
|
||||
from paskia.db.structs import ResetToken
|
||||
from paskia.util import hostutil
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -22,6 +23,11 @@ if TYPE_CHECKING:
|
||||
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:
|
||||
return datetime.now(UTC) + EXPIRES
|
||||
|
||||
@@ -30,10 +36,10 @@ def reset_expires() -> datetime:
|
||||
return datetime.now(UTC) + RESET_LIFETIME
|
||||
|
||||
|
||||
def get_reset(token: str) -> "ResetToken":
|
||||
def get_reset(token: str) -> ResetToken:
|
||||
"""Validate a credential reset token."""
|
||||
|
||||
record = db.get_reset_token(token)
|
||||
record = ResetToken.by_passphrase(token)
|
||||
if record:
|
||||
return record
|
||||
raise ValueError("This authentication link is no longer valid.")
|
||||
@@ -41,7 +47,7 @@ def get_reset(token: str) -> "ResetToken":
|
||||
|
||||
def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
|
||||
"""Delete a specific credential for the current user."""
|
||||
ctx = db.data().session_ctx(auth, hostutil.normalize_host(host))
|
||||
ctx = session_ctx(auth, host)
|
||||
if not ctx:
|
||||
raise ValueError("Session expired")
|
||||
db.delete_credential(credential_uuid, ctx.user.uuid)
|
||||
|
||||
+50
-65
@@ -4,44 +4,37 @@ Bootstrap module for passkey authentication system.
|
||||
This module handles initial system setup when a new database is created,
|
||||
including creating default admin user, organization, permissions, and
|
||||
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
|
||||
|
||||
from paskia import authsession, db, globals
|
||||
from paskia.util import hostutil, passphrase
|
||||
from paskia import authsession, db
|
||||
from paskia.db.bootstrap import log_reset_link
|
||||
from paskia.db.structs import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 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!
|
||||
"""
|
||||
def _configure_logger() -> None:
|
||||
if logger.handlers:
|
||||
return
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
|
||||
|
||||
def _log_reset_link(message: str, passphrase: str) -> str:
|
||||
_configure_logger()
|
||||
|
||||
|
||||
def _log_reset_link(passphrase: str, message: str | None = None) -> str:
|
||||
"""Log a reset link message and return the URL."""
|
||||
reset_link = hostutil.reset_link_url(passphrase)
|
||||
logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
|
||||
return reset_link
|
||||
|
||||
|
||||
async def bootstrap_system() -> None:
|
||||
"""
|
||||
Bootstrap the entire system with default data.
|
||||
|
||||
Uses db.bootstrap() which performs all operations in a single transaction.
|
||||
The transaction log will show a single "bootstrap" action with all changes.
|
||||
"""
|
||||
# Call the single-transaction bootstrap function
|
||||
reset_passphrase = db.bootstrap()
|
||||
|
||||
# Log the reset link (this is separate from the transaction log)
|
||||
_log_reset_link("✅ Bootstrap completed!", reset_passphrase)
|
||||
return log_reset_link(passphrase, message)
|
||||
|
||||
|
||||
async def check_admin_credentials() -> bool:
|
||||
@@ -52,17 +45,24 @@ async def check_admin_credentials() -> bool:
|
||||
bool: True if a reset link was created, False if admin already has credentials
|
||||
"""
|
||||
try:
|
||||
# Get permission organizations to find admin users
|
||||
# Find the auth:admin permission
|
||||
p = next(
|
||||
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
|
||||
)
|
||||
if not p or not p.orgs:
|
||||
if not p:
|
||||
return False
|
||||
|
||||
# Get users from the first organization with admin permission
|
||||
first_org_uuid = next(iter(p.orgs))
|
||||
org_users = db.get_organization_users(first_org_uuid)
|
||||
admin_users = [user for user, role in org_users if role == "Administration"]
|
||||
perm_uuid = p.uuid
|
||||
|
||||
# Find all roles that have the auth:admin permission
|
||||
admin_roles = [
|
||||
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:
|
||||
return False
|
||||
@@ -70,18 +70,17 @@ async def check_admin_credentials() -> bool:
|
||||
# Check first admin user for credentials
|
||||
admin_user = admin_users[0]
|
||||
|
||||
if not db.get_user_credential_ids(admin_user.uuid):
|
||||
if not admin_user.credential_ids:
|
||||
# Admin exists but has no credentials, create reset link
|
||||
logger.info("⚠️ Admin user has no credentials!")
|
||||
|
||||
token = passphrase.generate()
|
||||
expiry = authsession.reset_expires()
|
||||
db.create_reset_token(
|
||||
token = db.create_reset_token(
|
||||
user_uuid=admin_user.uuid,
|
||||
passphrase=token,
|
||||
expiry=expiry,
|
||||
token_type="admin registration",
|
||||
)
|
||||
_log_reset_link("⚠️ Admin user has no credentials!", token)
|
||||
_log_reset_link(token)
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -90,34 +89,20 @@ async def check_admin_credentials() -> bool:
|
||||
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:
|
||||
bool: True if bootstrapping was performed, False if system was already set up
|
||||
bool: Always returns False (bootstrapping is performed during init).
|
||||
"""
|
||||
# Check if the admin permission exists - if it does, system is already bootstrapped
|
||||
if any(p.scope == "auth:admin" for p in db.data().permissions.values()):
|
||||
# Permission exists, system is already bootstrapped
|
||||
# Check if admin needs credentials (only for already-bootstrapped systems)
|
||||
await check_admin_credentials()
|
||||
return False
|
||||
|
||||
# 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())
|
||||
await check_admin_credentials()
|
||||
return False
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
# Shared configuration constants for session management.
|
||||
@@ -6,19 +5,3 @@ SESSION_LIFETIME = timedelta(hours=24)
|
||||
|
||||
# Lifetime for reset links created by admins
|
||||
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
|
||||
|
||||
+23
-37
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Database module for WebAuthn passkey authentication.
|
||||
|
||||
Read: Access data() directly, use build_* to convert to public structs.
|
||||
Read: Access data() directly for structs.
|
||||
CTX: data().session_ctx(key) returns SessionContext with effective permissions.
|
||||
Write: Functions validate and commit, or raise ValueError.
|
||||
|
||||
@@ -10,7 +10,6 @@ Usage:
|
||||
|
||||
# Read (after init)
|
||||
user_data = db.data().users[user_uuid]
|
||||
user = db.build_user(user_uuid)
|
||||
|
||||
# Context
|
||||
ctx = db.data().session_ctx(session_key)
|
||||
@@ -20,26 +19,20 @@ Usage:
|
||||
"""
|
||||
|
||||
import paskia.db.operations as operations
|
||||
from paskia.db.background import (
|
||||
start_background,
|
||||
start_cleanup,
|
||||
stop_background,
|
||||
stop_cleanup,
|
||||
)
|
||||
from paskia.db.bootstrap import bootstrap
|
||||
from paskia.db.operations import (
|
||||
add_permission_to_org,
|
||||
add_permission_to_role,
|
||||
bootstrap,
|
||||
cleanup_expired,
|
||||
create_credential,
|
||||
create_credential_session,
|
||||
create_oid_client,
|
||||
create_org,
|
||||
create_permission,
|
||||
create_reset_token,
|
||||
create_role,
|
||||
create_session,
|
||||
create_user,
|
||||
delete_credential,
|
||||
delete_oid_client,
|
||||
delete_org,
|
||||
delete_permission,
|
||||
delete_reset_token,
|
||||
@@ -47,26 +40,28 @@ from paskia.db.operations import (
|
||||
delete_session,
|
||||
delete_sessions_for_user,
|
||||
delete_user,
|
||||
get_organization_users,
|
||||
get_reset_token,
|
||||
get_user_credential_ids,
|
||||
get_user_organization,
|
||||
init,
|
||||
is_username_taken,
|
||||
login,
|
||||
oidc_login,
|
||||
remove_permission_from_org,
|
||||
remove_permission_from_role,
|
||||
reset_oid_client_secret,
|
||||
set_session_host,
|
||||
update_config,
|
||||
update_credential_sign_count,
|
||||
update_oid_client,
|
||||
update_org_name,
|
||||
update_permission,
|
||||
update_role_name,
|
||||
update_session,
|
||||
update_user_display_name,
|
||||
update_user_info,
|
||||
update_user_role,
|
||||
update_user_role_in_organization,
|
||||
)
|
||||
from paskia.db.structs import (
|
||||
DB,
|
||||
Client,
|
||||
Config,
|
||||
Credential,
|
||||
Org,
|
||||
Permission,
|
||||
@@ -85,8 +80,10 @@ def data() -> DB:
|
||||
|
||||
__all__ = [
|
||||
# Types
|
||||
"Config",
|
||||
"Credential",
|
||||
"DB",
|
||||
"Client",
|
||||
"Org",
|
||||
"Permission",
|
||||
"ResetToken",
|
||||
@@ -96,36 +93,17 @@ __all__ = [
|
||||
"User",
|
||||
# Instance
|
||||
"data",
|
||||
"init",
|
||||
# Background
|
||||
"start_background",
|
||||
"stop_background",
|
||||
"start_cleanup",
|
||||
"stop_cleanup",
|
||||
# Builders
|
||||
"build_credential",
|
||||
"build_permission",
|
||||
"build_reset_token",
|
||||
"build_role",
|
||||
"build_session",
|
||||
"build_user",
|
||||
# Read ops
|
||||
"get_organization_users",
|
||||
"get_reset_token",
|
||||
"get_user_credential_ids",
|
||||
"get_user_organization",
|
||||
# Write ops
|
||||
"add_permission_to_org",
|
||||
"add_permission_to_role",
|
||||
"bootstrap",
|
||||
"cleanup_expired",
|
||||
"create_credential",
|
||||
"create_credential_session",
|
||||
"create_org",
|
||||
"create_permission",
|
||||
"create_reset_token",
|
||||
"create_role",
|
||||
"create_session",
|
||||
"create_user",
|
||||
"delete_credential",
|
||||
"delete_org",
|
||||
@@ -136,15 +114,23 @@ __all__ = [
|
||||
"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",
|
||||
"update_user_role_in_organization",
|
||||
"is_username_taken",
|
||||
# OIDC
|
||||
"create_oid_client",
|
||||
"update_oid_client",
|
||||
"reset_oid_client_secret",
|
||||
"delete_oid_client",
|
||||
]
|
||||
|
||||
+16
-44
@@ -1,62 +1,38 @@
|
||||
"""
|
||||
Background task for database maintenance.
|
||||
|
||||
Periodically flushes pending changes to disk and cleans up expired items.
|
||||
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 datetime import UTC, datetime
|
||||
|
||||
from paskia.db.operations import _store, cleanup_expired
|
||||
from paskia.db.lifecycle import cleanup_expired
|
||||
|
||||
FLUSH_INTERVAL = 0.1 # Flush to disk
|
||||
CLEANUP_INTERVAL = 1 # Expired item cleanup
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_background_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
async def flush() -> None:
|
||||
"""Write all pending database changes to disk."""
|
||||
|
||||
if _store is None:
|
||||
_logger.warning("flush() called but _store is None")
|
||||
return
|
||||
await _store.flush()
|
||||
|
||||
|
||||
async def _background_loop():
|
||||
"""Background task that periodically flushes changes and cleans up."""
|
||||
"""Background task that periodically cleans up expired items."""
|
||||
# Run cleanup immediately on startup to clear old expired items
|
||||
cleanup_expired()
|
||||
await flush()
|
||||
|
||||
last_cleanup = datetime.now(UTC)
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(FLUSH_INTERVAL)
|
||||
# Flush pending changes to disk
|
||||
await flush()
|
||||
|
||||
# Run cleanup periodically
|
||||
now = datetime.now(UTC)
|
||||
if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL:
|
||||
cleanup_expired()
|
||||
await flush() # Flush cleanup changes
|
||||
last_cleanup = now
|
||||
await asyncio.sleep(CLEANUP_INTERVAL)
|
||||
cleanup_expired()
|
||||
except asyncio.CancelledError:
|
||||
# Final flush before exit
|
||||
await flush()
|
||||
break
|
||||
except Exception:
|
||||
_logger.debug("Error in database background loop", exc_info=True)
|
||||
|
||||
|
||||
async def start_background():
|
||||
"""Start the background flush/cleanup task."""
|
||||
"""Start the background cleanup task."""
|
||||
global _background_task
|
||||
|
||||
# Check if task exists but is no longer running (e.g., after uvicorn reload)
|
||||
@@ -70,29 +46,25 @@ async def start_background():
|
||||
# Check if task is in current event loop
|
||||
loop = asyncio.get_running_loop()
|
||||
task_loop = _background_task.get_loop()
|
||||
if loop is not task_loop:
|
||||
_logger.debug("Background task in different event loop, restarting")
|
||||
_background_task = None
|
||||
else:
|
||||
# Task is running in the same event loop - this is an error
|
||||
raise RuntimeError(
|
||||
"Background task is already running. "
|
||||
"start_background() must not be called multiple times in the same event 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"
|
||||
)
|
||||
except RuntimeError:
|
||||
raise # Re-raise RuntimeError from above
|
||||
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())
|
||||
else:
|
||||
_logger.debug("Background task already running: %s", _background_task)
|
||||
|
||||
|
||||
async def stop_background():
|
||||
"""Stop the background task and flush any pending changes."""
|
||||
"""Stop the background cleanup task."""
|
||||
global _background_task
|
||||
if _background_task:
|
||||
_background_task.cancel()
|
||||
|
||||
@@ -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
|
||||
@@ -1,282 +0,0 @@
|
||||
"""
|
||||
JSONL persistence layer for the database.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from collections import deque
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiofiles
|
||||
import jsondiff
|
||||
import msgspec
|
||||
|
||||
from paskia.db.logging import log_change
|
||||
from paskia.db.migrations import DBVER, apply_all_migrations
|
||||
from paskia.db.structs import DB, SessionContext
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Default database path
|
||||
DB_PATH_DEFAULT = "paskia.jsonl"
|
||||
|
||||
|
||||
class _ChangeRecord(msgspec.Struct, omit_defaults=True):
|
||||
"""A single change record in the JSONL file."""
|
||||
|
||||
ts: datetime
|
||||
a: str # action - describes the operation (e.g., "migrate", "login", "create_user")
|
||||
v: int # schema version after this change
|
||||
u: str | None = None # user UUID who performed the action (None for system)
|
||||
diff: dict = {}
|
||||
|
||||
|
||||
# msgspec encoder for change records
|
||||
_change_encoder = msgspec.json.Encoder()
|
||||
|
||||
|
||||
def compute_diff(previous: dict, current: dict) -> dict | None:
|
||||
"""Compute JSON diff between two states.
|
||||
|
||||
Args:
|
||||
previous: Previous state (JSON-compatible dict)
|
||||
current: Current state (JSON-compatible dict)
|
||||
|
||||
Returns:
|
||||
The diff, or None if no changes
|
||||
"""
|
||||
diff = jsondiff.diff(previous, current, marshal=True)
|
||||
return diff if diff else None
|
||||
|
||||
|
||||
def create_change_record(
|
||||
action: str, version: int, diff: dict, user: str | None = None
|
||||
) -> _ChangeRecord:
|
||||
"""Create a change record for persistence."""
|
||||
return _ChangeRecord(
|
||||
ts=datetime.now(UTC),
|
||||
a=action,
|
||||
v=version,
|
||||
u=user,
|
||||
diff=diff,
|
||||
)
|
||||
|
||||
|
||||
# Actions that are allowed to create a new database file
|
||||
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap", "migrate:sql"})
|
||||
|
||||
|
||||
async def flush_changes(
|
||||
db_path: Path,
|
||||
pending_changes: deque[_ChangeRecord],
|
||||
) -> bool:
|
||||
"""Write all pending changes to disk.
|
||||
|
||||
Args:
|
||||
db_path: Path to the JSONL database file
|
||||
pending_changes: Queue of pending change records (will be cleared on success)
|
||||
|
||||
Returns:
|
||||
True if flush succeeded, False otherwise
|
||||
"""
|
||||
if not pending_changes:
|
||||
return True
|
||||
|
||||
if not db_path.exists():
|
||||
first_action = pending_changes[0].a
|
||||
if first_action not in _BOOTSTRAP_ACTIONS:
|
||||
_logger.error(
|
||||
"Refusing to create database file with action '%s' - "
|
||||
"only bootstrap or migrate can create a new database",
|
||||
first_action,
|
||||
)
|
||||
pending_changes.clear()
|
||||
return False
|
||||
|
||||
changes_to_write = list(pending_changes)
|
||||
pending_changes.clear()
|
||||
|
||||
try:
|
||||
lines = [_change_encoder.encode(change) for change in changes_to_write]
|
||||
if not lines:
|
||||
return True
|
||||
|
||||
async with aiofiles.open(db_path, "ab") as f:
|
||||
await f.write(b"\n".join(lines) + b"\n")
|
||||
return True
|
||||
except OSError:
|
||||
_logger.exception("Failed to flush database changes")
|
||||
# Re-queue the changes on failure
|
||||
for change in reversed(changes_to_write):
|
||||
pending_changes.appendleft(change)
|
||||
return False
|
||||
|
||||
|
||||
class JsonlStore:
|
||||
"""JSONL persistence layer for a DB instance."""
|
||||
|
||||
def __init__(self, db: DB, db_path: str = DB_PATH_DEFAULT):
|
||||
self.db: DB = db
|
||||
self.db_path = Path(db_path)
|
||||
self._previous_builtins: dict[str, Any] = {}
|
||||
self._pending_changes: deque[_ChangeRecord] = deque()
|
||||
self._current_action: str = "system"
|
||||
self._current_user: str | None = None
|
||||
self._in_transaction: bool = False
|
||||
self._transaction_snapshot: dict[str, Any] | None = None
|
||||
self._current_version: int = DBVER # Schema version for new databases
|
||||
|
||||
async def load(self, db_path: str | None = None) -> None:
|
||||
"""Load data from JSONL change log."""
|
||||
if db_path is not None:
|
||||
self.db_path = Path(db_path)
|
||||
if not self.db_path.exists():
|
||||
return
|
||||
|
||||
# Replay change log to reconstruct state
|
||||
data_dict: dict = {}
|
||||
try:
|
||||
async with aiofiles.open(self.db_path, "rb") as f:
|
||||
content = await f.read()
|
||||
for line_num, line in enumerate(content.split(b"\n"), 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
change = msgspec.json.decode(line)
|
||||
data_dict = jsondiff.patch(data_dict, change["diff"], marshal=True)
|
||||
self._current_version = change.get("v", 0)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Error parsing line {line_num}: {e}")
|
||||
except (OSError, ValueError, msgspec.DecodeError) as e:
|
||||
raise ValueError(f"Failed to load database: {e}")
|
||||
|
||||
if not data_dict:
|
||||
return
|
||||
|
||||
# Set previous state for diffing (will be updated by _queue_change)
|
||||
self._previous_builtins = copy.deepcopy(data_dict)
|
||||
|
||||
# Callback to persist each migration
|
||||
async def persist_migration(
|
||||
action: str, new_version: int, current: dict
|
||||
) -> None:
|
||||
self._current_version = new_version
|
||||
self._queue_change(action, new_version, current)
|
||||
|
||||
# Apply schema migrations one at a time
|
||||
await apply_all_migrations(data_dict, self._current_version, persist_migration)
|
||||
|
||||
# Decode to msgspec struct
|
||||
decoder = msgspec.json.Decoder(DB)
|
||||
self.db = decoder.decode(msgspec.json.encode(data_dict))
|
||||
self.db._store = self
|
||||
|
||||
# Normalize via msgspec round-trip (handles omit_defaults etc.)
|
||||
# This ensures _previous_builtins matches what msgspec would produce
|
||||
normalized_dict = msgspec.to_builtins(self.db)
|
||||
await persist_migration(
|
||||
"migrate:msgspec", self._current_version, normalized_dict
|
||||
)
|
||||
|
||||
def _queue_change(
|
||||
self, action: str, version: int, current: dict, user: str | None = None
|
||||
) -> None:
|
||||
"""Queue a change record and log it.
|
||||
|
||||
Args:
|
||||
action: The action name for the change record
|
||||
version: The schema version for the change record
|
||||
current: The current state as a plain dict
|
||||
user: Optional user UUID who performed the action
|
||||
"""
|
||||
diff = compute_diff(self._previous_builtins, current)
|
||||
if not diff:
|
||||
return
|
||||
self._pending_changes.append(create_change_record(action, version, diff, user))
|
||||
self._previous_builtins = copy.deepcopy(current)
|
||||
|
||||
# Log the change with user display name if available
|
||||
user_display = None
|
||||
if user:
|
||||
try:
|
||||
user_uuid = UUID(user)
|
||||
if user_uuid in self.db.users:
|
||||
user_display = self.db.users[user_uuid].display_name
|
||||
except (ValueError, KeyError):
|
||||
user_display = user
|
||||
|
||||
log_change(action, diff, user_display)
|
||||
|
||||
@contextmanager
|
||||
def transaction(
|
||||
self,
|
||||
action: str,
|
||||
ctx: SessionContext | None = None,
|
||||
*,
|
||||
user: str | None = None,
|
||||
):
|
||||
"""Wrap writes in transaction. Queues change on successful exit.
|
||||
|
||||
Args:
|
||||
action: Describes the operation (e.g., "Created user", "Login")
|
||||
ctx: Session context of user performing the action (None for system operations)
|
||||
user: User UUID string (alternative to ctx when full context unavailable)
|
||||
"""
|
||||
if self._in_transaction:
|
||||
raise RuntimeError("Nested transactions are not supported")
|
||||
|
||||
# Check for out-of-transaction modifications
|
||||
current_state = msgspec.to_builtins(self.db)
|
||||
if current_state != self._previous_builtins:
|
||||
# Allow bootstrap/migrate to create a new database from empty state
|
||||
is_bootstrap = action in _BOOTSTRAP_ACTIONS or action.startswith("migrate:")
|
||||
if is_bootstrap and not self._previous_builtins:
|
||||
pass # Expected: creating database from scratch
|
||||
else:
|
||||
diff = compute_diff(self._previous_builtins, current_state)
|
||||
diff_json = msgspec.json.encode(diff).decode()
|
||||
_logger.critical(
|
||||
"Database state modified outside of transaction! "
|
||||
"This indicates a bug where DB changes occurred without a transaction wrapper.\n"
|
||||
f"Changes detected:\n{diff_json}"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
old_action = self._current_action
|
||||
old_user = self._current_user
|
||||
self._current_action = action
|
||||
# Prefer ctx.user.uuid if ctx provided, otherwise use user param
|
||||
self._current_user = str(ctx.user.uuid) if ctx else user
|
||||
self._in_transaction = True
|
||||
self._transaction_snapshot = current_state
|
||||
|
||||
try:
|
||||
yield
|
||||
current = msgspec.to_builtins(self.db)
|
||||
self._queue_change(
|
||||
self._current_action, self._current_version, current, self._current_user
|
||||
)
|
||||
except Exception:
|
||||
# Rollback on error: restore from snapshot
|
||||
_logger.warning("Transaction '%s' failed, rolling back changes", action)
|
||||
if self._transaction_snapshot is not None:
|
||||
decoder = msgspec.json.Decoder(DB)
|
||||
self.db = decoder.decode(
|
||||
msgspec.json.encode(self._transaction_snapshot)
|
||||
)
|
||||
self.db._store = self
|
||||
raise
|
||||
finally:
|
||||
self._current_action = old_action
|
||||
self._current_user = old_user
|
||||
self._in_transaction = False
|
||||
self._transaction_snapshot = None
|
||||
|
||||
async def flush(self) -> bool:
|
||||
"""Write all pending changes to disk."""
|
||||
return await flush_changes(self.db_path, self._pending_changes)
|
||||
@@ -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)
|
||||
@@ -1,233 +0,0 @@
|
||||
"""
|
||||
Database change logging with pretty-printed diffs.
|
||||
|
||||
Provides a logger for JSONL database changes that formats diffs
|
||||
in a human-readable path.notation style with color coding.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("paskia.db")
|
||||
|
||||
# Pattern to match control characters and bidirectional overrides
|
||||
_UNSAFE_CHARS = re.compile(
|
||||
r"[\x00-\x1f\x7f-\x9f" # C0 and C1 control characters
|
||||
r"\u200e\u200f" # LRM, RLM
|
||||
r"\u202a-\u202e" # LRE, RLE, PDF, LRO, RLO
|
||||
r"\u2066-\u2069" # LRI, RLI, FSI, PDI
|
||||
r"]"
|
||||
)
|
||||
|
||||
# ANSI color codes (matching FastAPI logging style)
|
||||
_RESET = "\033[0m"
|
||||
_DIM = "\033[2m"
|
||||
_PATH_PREFIX = "\033[1;30m" # Dark grey for path prefix (like host in access log)
|
||||
_PATH_FINAL = "\033[0m" # Default for final element (like path in access log)
|
||||
_REPLACE = "\033[0;33m" # Yellow for replacements
|
||||
_DELETE = "\033[0;31m" # Red for deletions
|
||||
_ADD = "\033[0;32m" # Green for additions
|
||||
_ACTION = "\033[1;34m" # Bold blue for action name
|
||||
_USER = "\033[0;34m" # Blue for user display
|
||||
|
||||
|
||||
def _use_color() -> bool:
|
||||
"""Check if we should use color output."""
|
||||
return sys.stderr.isatty()
|
||||
|
||||
|
||||
def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
|
||||
"""Format a value for display, truncating if needed."""
|
||||
if value is None:
|
||||
return "null"
|
||||
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
|
||||
if isinstance(value, str):
|
||||
# Filter out control characters and bidirectional overrides
|
||||
value = _UNSAFE_CHARS.sub("", value)
|
||||
# Truncate long strings
|
||||
if len(value) > max_len:
|
||||
return value[: max_len - 3] + "..."
|
||||
return value
|
||||
|
||||
if isinstance(value, dict):
|
||||
if not value:
|
||||
return "{}"
|
||||
# For small dicts, show inline
|
||||
if len(value) == 1:
|
||||
k, v = next(iter(value.items()))
|
||||
return "{" + f"{k}: {_format_value(v, use_color, max_len=30)}" + "}"
|
||||
return f"{{...{len(value)} keys}}"
|
||||
|
||||
if isinstance(value, list):
|
||||
if not value:
|
||||
return "[]"
|
||||
if len(value) == 1:
|
||||
return "[" + _format_value(value[0], use_color, max_len=30) + "]"
|
||||
return f"[...{len(value)} items]"
|
||||
|
||||
# Fallback for other types
|
||||
text = str(value)
|
||||
if len(text) > max_len:
|
||||
text = text[: max_len - 3] + "..."
|
||||
return text
|
||||
|
||||
|
||||
def _format_path(path: list[str], use_color: bool) -> str:
|
||||
"""Format a path as dot notation with prefix in dark grey, final in default."""
|
||||
if not path:
|
||||
return ""
|
||||
if not use_color:
|
||||
return ".".join(path)
|
||||
if len(path) == 1:
|
||||
return f"{_PATH_FINAL}{path[0]}{_RESET}"
|
||||
prefix = ".".join(path[:-1])
|
||||
final = path[-1]
|
||||
return f"{_PATH_PREFIX}{prefix}.{_RESET}{_PATH_FINAL}{final}{_RESET}"
|
||||
|
||||
|
||||
def _collect_changes(
|
||||
diff: dict, path: list[str], changes: list[tuple[str, list[str], Any, Any | None]]
|
||||
) -> None:
|
||||
"""
|
||||
Recursively collect changes from a diff into a flat list.
|
||||
|
||||
Each change is a tuple of (change_type, path, new_value, old_value).
|
||||
change_type is one of: 'set', 'replace', 'delete'
|
||||
"""
|
||||
if not isinstance(diff, dict):
|
||||
# Leaf value - this is a set operation
|
||||
changes.append(("set", path, diff, None))
|
||||
return
|
||||
|
||||
for key, value in diff.items():
|
||||
if key == "$delete":
|
||||
# $delete contains a list of keys to delete
|
||||
if isinstance(value, list):
|
||||
for deleted_key in value:
|
||||
changes.append(("delete", path + [str(deleted_key)], None, None))
|
||||
else:
|
||||
changes.append(("delete", path + [str(value)], None, None))
|
||||
|
||||
elif key == "$replace":
|
||||
# $replace contains the new value for this path
|
||||
if isinstance(value, dict):
|
||||
# Replacing with a dict - show each key as a replacement
|
||||
for rkey, rval in value.items():
|
||||
changes.append(("replace", path + [str(rkey)], rval, None))
|
||||
if not value:
|
||||
# Empty replacement - clearing the collection
|
||||
changes.append(("replace", path, {}, None))
|
||||
else:
|
||||
changes.append(("replace", path, value, None))
|
||||
|
||||
elif key.startswith("$"):
|
||||
# Other special operations (future-proofing)
|
||||
changes.append(("set", path, {key: value}, None))
|
||||
|
||||
else:
|
||||
# Regular nested key
|
||||
_collect_changes(value, path + [str(key)], changes)
|
||||
|
||||
|
||||
def _format_change_line(
|
||||
change_type: str, path: list[str], value: Any, use_color: bool
|
||||
) -> str:
|
||||
"""Format a single change as a one-line string."""
|
||||
path_str = _format_path(path, use_color)
|
||||
value_str = _format_value(value, use_color)
|
||||
|
||||
if change_type == "delete":
|
||||
if use_color:
|
||||
return f" ❌ {path_str}"
|
||||
return f" - {path_str}"
|
||||
|
||||
if change_type == "replace":
|
||||
if use_color:
|
||||
return f" {_REPLACE}⟳{_RESET} {path_str} {_DIM}={_RESET} {value_str}"
|
||||
return f" ~ {path_str} = {value_str}"
|
||||
|
||||
# Default: set/add
|
||||
if use_color:
|
||||
return f" {_ADD}+{_RESET} {path_str} {_DIM}={_RESET} {value_str}"
|
||||
return f" + {path_str} = {value_str}"
|
||||
|
||||
|
||||
def format_diff(diff: dict) -> list[str]:
|
||||
"""
|
||||
Format a JSON diff as human-readable lines.
|
||||
|
||||
Returns a list of formatted lines (without newlines).
|
||||
Single changes return one line, multiple changes return multiple lines.
|
||||
"""
|
||||
use_color = _use_color()
|
||||
changes: list[tuple[str, list[str], Any, Any | None]] = []
|
||||
_collect_changes(diff, [], changes)
|
||||
|
||||
if not changes:
|
||||
return []
|
||||
|
||||
# Format each change
|
||||
lines = []
|
||||
for change_type, path, value, _ in changes:
|
||||
lines.append(_format_change_line(change_type, path, value, use_color))
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def format_action_header(action: str, user_display: str | None = None) -> str:
|
||||
"""Format the action header line."""
|
||||
use_color = _use_color()
|
||||
|
||||
if use_color:
|
||||
action_str = f"{_ACTION}{action}{_RESET}"
|
||||
if user_display:
|
||||
user_str = f"{_USER}{user_display}{_RESET}"
|
||||
return f"{action_str} by {user_str}"
|
||||
return action_str
|
||||
else:
|
||||
if user_display:
|
||||
return f"{action} by {user_display}"
|
||||
return action
|
||||
|
||||
|
||||
def log_change(action: str, diff: dict, user_display: str | None = None) -> None:
|
||||
"""
|
||||
Log a database change with pretty-printed diff.
|
||||
|
||||
Args:
|
||||
action: The action name (e.g., "login", "admin:delete_user")
|
||||
diff: The JSON diff dict
|
||||
user_display: Optional display name of the user who performed the action
|
||||
"""
|
||||
header = format_action_header(action, user_display)
|
||||
diff_lines = format_diff(diff)
|
||||
|
||||
if not diff_lines:
|
||||
logger.info(header)
|
||||
return
|
||||
|
||||
if len(diff_lines) == 1:
|
||||
# Single change - combine on one line
|
||||
logger.info(f"{header}{diff_lines[0]}")
|
||||
else:
|
||||
# Multiple changes - header on its own line, then changes
|
||||
logger.info(header)
|
||||
for line in diff_lines:
|
||||
logger.info(line)
|
||||
|
||||
|
||||
def configure_db_logging() -> None:
|
||||
"""Configure the database logger to output to stderr without prefix."""
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
+31
-16
@@ -5,7 +5,11 @@ Migrations are applied during database load based on the version field.
|
||||
Each migration should be idempotent and only run when needed.
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
import base64
|
||||
|
||||
from kanta import Kanta
|
||||
|
||||
from paskia.util.crypto import secret_key
|
||||
|
||||
|
||||
def migrate_v1(d: dict) -> None:
|
||||
@@ -14,20 +18,31 @@ def migrate_v1(d: dict) -> None:
|
||||
org_data.pop("created_at", None)
|
||||
|
||||
|
||||
migrations = sorted(
|
||||
[f for n, f in globals().items() if n.startswith("migrate_v")],
|
||||
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
|
||||
)
|
||||
|
||||
DBVER = len(migrations) # Used by bootstrap and migrate:sql to set initial version
|
||||
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}
|
||||
|
||||
|
||||
async def apply_all_migrations(
|
||||
data_dict: dict,
|
||||
current_version: int,
|
||||
persist: Callable[[str, int, dict], Awaitable[None]],
|
||||
) -> None:
|
||||
while current_version < DBVER:
|
||||
migrations[current_version](data_dict)
|
||||
current_version += 1
|
||||
await persist(f"migrate:v{current_version}", current_version, data_dict)
|
||||
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]
|
||||
|
||||
+332
-359
@@ -6,22 +6,19 @@ Context lookup: _db.session_ctx() returns full SessionContext with effective per
|
||||
Write operations: Functions that validate and commit, or raise ValueError.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
import uuid7
|
||||
|
||||
from paskia import oidc_notify
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db.jsonl import (
|
||||
DB_PATH_DEFAULT,
|
||||
JsonlStore,
|
||||
)
|
||||
from paskia.db.structs import (
|
||||
DB,
|
||||
Client,
|
||||
Config,
|
||||
Credential,
|
||||
Org,
|
||||
Permission,
|
||||
@@ -31,94 +28,47 @@ from paskia.db.structs import (
|
||||
SessionContext,
|
||||
User,
|
||||
)
|
||||
from paskia.util.passphrase import generate as generate_passphrase
|
||||
from paskia.util.passphrase import is_well_formed as _is_passphrase
|
||||
from paskia.util.crypto import hash_secret
|
||||
from paskia.util.nameutil import slugify_name
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Sentinel for distinguishing "not provided" from None
|
||||
_UNSET = object()
|
||||
|
||||
# Global database instance (empty until init() loads data)
|
||||
_db = DB()
|
||||
_store = JsonlStore(_db)
|
||||
_db._store = _store
|
||||
_initialized = False
|
||||
_db = DB(config=Config(rp_id="uninitialized.invalid"))
|
||||
|
||||
|
||||
async def init(*args, **kwargs):
|
||||
"""Load database from JSONL file."""
|
||||
global _db, _initialized
|
||||
if _initialized:
|
||||
_logger.debug("Database already initialized, skipping reload")
|
||||
return
|
||||
db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT)
|
||||
if db_path.startswith("json:"):
|
||||
db_path = db_path[5:]
|
||||
await _store.load(db_path)
|
||||
_db = _store.db
|
||||
_initialized = True
|
||||
def _store():
|
||||
"""Return active Kanta instance for the current DB object."""
|
||||
store = _db._store
|
||||
if store is None:
|
||||
raise RuntimeError("Kanta store is not initialized")
|
||||
return store
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Read/lookup functions
|
||||
# -------------------------------------------------------------------------
|
||||
def _transaction(
|
||||
action: str,
|
||||
ctx: SessionContext | None = None,
|
||||
*,
|
||||
user: str | None = None,
|
||||
mtime: bool | datetime = True,
|
||||
):
|
||||
"""Create a Kanta transaction with minimal metadata mapping."""
|
||||
user_id = str(ctx.user.uuid) if ctx else user
|
||||
return _store().transaction(action, user=user_id, mtime=mtime)
|
||||
|
||||
|
||||
def get_user_organization(user_uuid: UUID) -> tuple[Org, str]:
|
||||
"""Get the organization a user belongs to and their role name.
|
||||
|
||||
Raises ValueError if user not found.
|
||||
|
||||
Call sites:
|
||||
- update_user_role_in_organization: org only
|
||||
- admin_create_user_registration_link: org only
|
||||
- admin_get_user_detail: org and role
|
||||
- admin_update_user_display_name: org only
|
||||
- admin_delete_user_credential: org only
|
||||
- admin_delete_user_session: org only
|
||||
"""
|
||||
if user_uuid not in _db.users:
|
||||
raise ValueError(f"User {user_uuid} not found")
|
||||
user = _db.users[user_uuid]
|
||||
role = user.role
|
||||
return role.org, role.display_name
|
||||
|
||||
|
||||
def get_organization_users(org_uuid: UUID) -> list[tuple[User, str]]:
|
||||
"""Get all users in an organization with their role names.
|
||||
|
||||
Returns list of (User, role_display_name) tuples.
|
||||
"""
|
||||
org = _db.orgs[org_uuid]
|
||||
return [(u, u.role.display_name) for role in org.roles for u in role.users]
|
||||
|
||||
|
||||
def get_user_credential_ids(user_uuid: UUID) -> list[bytes]:
|
||||
"""Get credential IDs for a user (for WebAuthn exclude lists).
|
||||
|
||||
Returns empty list if user has no credentials.
|
||||
"""
|
||||
assert user_uuid
|
||||
return [c.credential_id for c in _db.users[user_uuid].credentials]
|
||||
|
||||
|
||||
def _reset_key(passphrase: str) -> bytes:
|
||||
"""Hash a passphrase to bytes for reset token storage."""
|
||||
if not _is_passphrase(passphrase):
|
||||
raise ValueError(
|
||||
"Trying to reset with a session token in place of a passphrase"
|
||||
if len(passphrase) == 16
|
||||
else "Invalid passphrase format"
|
||||
)
|
||||
return hashlib.sha512(passphrase.encode()).digest()[:9]
|
||||
|
||||
|
||||
def get_reset_token(passphrase: str) -> ResetToken | None:
|
||||
"""Get reset token by passphrase.
|
||||
|
||||
Call sites:
|
||||
- Get reset token to validate it (authsession.py:34)
|
||||
"""
|
||||
key = _reset_key(passphrase)
|
||||
return _db.reset_tokens.get(key)
|
||||
def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
|
||||
"""Check if a preferred_username is already taken by another user."""
|
||||
if not username:
|
||||
return False
|
||||
for user in _db.users.values():
|
||||
if user.preferred_username == username:
|
||||
if exclude_uuid is None or user.uuid != exclude_uuid:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -126,12 +76,18 @@ def get_reset_token(passphrase: str) -> ResetToken | None:
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def update_config(config: Config) -> None:
|
||||
"""Update the stored configuration."""
|
||||
with _transaction("update_config"):
|
||||
_db.config = config
|
||||
|
||||
|
||||
def create_permission(perm: Permission, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Create a new permission."""
|
||||
if perm.uuid in _db.permissions:
|
||||
raise ValueError(f"Permission {perm.uuid} already exists")
|
||||
with _db.transaction("admin:create_permission", ctx):
|
||||
_db.permissions[perm.uuid] = perm
|
||||
with _transaction("admin:create_permission", ctx):
|
||||
perm.store()
|
||||
|
||||
|
||||
def update_permission(
|
||||
@@ -148,7 +104,7 @@ def update_permission(
|
||||
"""
|
||||
if uuid not in _db.permissions:
|
||||
raise ValueError(f"Permission {uuid} not found")
|
||||
with _db.transaction("admin:update_permission", ctx):
|
||||
with _transaction("admin:update_permission", ctx):
|
||||
_db.permissions[uuid].scope = scope
|
||||
_db.permissions[uuid].display_name = display_name
|
||||
_db.permissions[uuid].domain = domain
|
||||
@@ -158,11 +114,8 @@ def delete_permission(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Delete a permission and remove it from all roles."""
|
||||
if uuid not in _db.permissions:
|
||||
raise ValueError(f"Permission {uuid} not found")
|
||||
with _db.transaction("admin:delete_permission", ctx):
|
||||
# Remove this permission from all roles
|
||||
for role in _db.roles.values():
|
||||
role.permissions.pop(uuid, None)
|
||||
del _db.permissions[uuid]
|
||||
with _transaction("admin:delete_permission", ctx):
|
||||
_db.permissions[uuid].delete()
|
||||
|
||||
|
||||
def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
|
||||
@@ -172,13 +125,14 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
|
||||
"""
|
||||
if org.uuid in _db.orgs:
|
||||
raise ValueError(f"Organization {org.uuid} already exists")
|
||||
with _db.transaction("admin:create_org", ctx):
|
||||
new_org = Org.create(display_name=org.display_name)
|
||||
now = datetime.now(UTC)
|
||||
with _transaction("admin:create_org", ctx):
|
||||
new_org = Org.create(display_name=org.display_name, created_at=now)
|
||||
new_org.uuid = org.uuid
|
||||
_db.orgs[org.uuid] = new_org
|
||||
new_org.store()
|
||||
# Create Administration role with org admin permission
|
||||
|
||||
admin_role_uuid = uuid7.create()
|
||||
admin_role_uuid = uuid7.create(now)
|
||||
# Find the auth:org:admin permission UUID
|
||||
org_admin_perm_uuid = None
|
||||
for pid, p in _db.permissions.items():
|
||||
@@ -192,7 +146,7 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
|
||||
permissions=role_permissions,
|
||||
)
|
||||
admin_role.uuid = admin_role_uuid
|
||||
_db.roles[admin_role_uuid] = admin_role
|
||||
admin_role.store()
|
||||
|
||||
|
||||
def update_org_name(
|
||||
@@ -204,7 +158,7 @@ def update_org_name(
|
||||
"""Update organization display name."""
|
||||
if uuid not in _db.orgs:
|
||||
raise ValueError(f"Organization {uuid} not found")
|
||||
with _db.transaction("admin:update_org_name", ctx):
|
||||
with _transaction("admin:update_org_name", ctx):
|
||||
_db.orgs[uuid].display_name = display_name
|
||||
|
||||
|
||||
@@ -212,17 +166,8 @@ def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Delete organization and all its roles/users."""
|
||||
if uuid not in _db.orgs:
|
||||
raise ValueError(f"Organization {uuid} not found")
|
||||
with _db.transaction("admin:delete_org", ctx):
|
||||
org = _db.orgs[uuid]
|
||||
# Remove org from all permissions
|
||||
for p in _db.permissions.values():
|
||||
p.orgs.pop(uuid, None)
|
||||
# Delete roles in this org and their users
|
||||
for role in org.roles:
|
||||
for user in role.users:
|
||||
del _db.users[user.uuid]
|
||||
del _db.roles[role.uuid]
|
||||
del _db.orgs[uuid]
|
||||
with _transaction("admin:delete_org", ctx):
|
||||
_db.orgs[uuid].delete()
|
||||
|
||||
|
||||
def add_permission_to_org(
|
||||
@@ -238,7 +183,7 @@ def add_permission_to_org(
|
||||
if permission_uuid not in _db.permissions:
|
||||
raise ValueError(f"Permission {permission_uuid} not found")
|
||||
|
||||
with _db.transaction("admin:add_permission_to_org", ctx):
|
||||
with _transaction("admin:add_permission_to_org", ctx):
|
||||
_db.permissions[permission_uuid].orgs[org_uuid] = True
|
||||
|
||||
|
||||
@@ -255,7 +200,7 @@ def remove_permission_from_org(
|
||||
if permission_uuid not in _db.permissions:
|
||||
return # Permission not found, silently return
|
||||
|
||||
with _db.transaction("admin:remove_permission_from_org", ctx):
|
||||
with _transaction("admin:remove_permission_from_org", ctx):
|
||||
_db.permissions[permission_uuid].orgs.pop(org_uuid, None)
|
||||
|
||||
|
||||
@@ -265,8 +210,8 @@ def create_role(role: Role, *, ctx: SessionContext | None = None) -> None:
|
||||
raise ValueError(f"Role {role.uuid} already exists")
|
||||
if role.org_uuid not in _db.orgs:
|
||||
raise ValueError(f"Organization {role.org_uuid} not found")
|
||||
with _db.transaction("admin:create_role", ctx):
|
||||
_db.roles[role.uuid] = role
|
||||
with _transaction("admin:create_role", ctx):
|
||||
role.store()
|
||||
|
||||
|
||||
def update_role_name(
|
||||
@@ -278,7 +223,7 @@ def update_role_name(
|
||||
"""Update role display name."""
|
||||
if uuid not in _db.roles:
|
||||
raise ValueError(f"Role {uuid} not found")
|
||||
with _db.transaction("admin:update_role_name", ctx):
|
||||
with _transaction("admin:update_role_name", ctx):
|
||||
_db.roles[uuid].display_name = display_name
|
||||
|
||||
|
||||
@@ -293,7 +238,7 @@ def add_permission_to_role(
|
||||
raise ValueError(f"Role {role_uuid} not found")
|
||||
if permission_uuid not in _db.permissions:
|
||||
raise ValueError(f"Permission {permission_uuid} not found")
|
||||
with _db.transaction("admin:add_permission_to_role", ctx):
|
||||
with _transaction("admin:add_permission_to_role", ctx):
|
||||
_db.roles[role_uuid].permissions[permission_uuid] = True
|
||||
|
||||
|
||||
@@ -306,7 +251,7 @@ def remove_permission_from_role(
|
||||
"""Remove permission from role by UUID."""
|
||||
if role_uuid not in _db.roles:
|
||||
raise ValueError(f"Role {role_uuid} not found")
|
||||
with _db.transaction("admin:remove_permission_from_role", ctx):
|
||||
with _transaction("admin:remove_permission_from_role", ctx):
|
||||
_db.roles[role_uuid].permissions.pop(permission_uuid, None)
|
||||
|
||||
|
||||
@@ -318,8 +263,8 @@ def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
role = _db.roles[uuid]
|
||||
if role.users:
|
||||
raise ValueError(f"Cannot delete role {uuid}: users still assigned")
|
||||
with _db.transaction("admin:delete_role", ctx):
|
||||
del _db.roles[uuid]
|
||||
with _transaction("admin:delete_role", ctx):
|
||||
_db.roles[uuid].delete()
|
||||
|
||||
|
||||
def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
|
||||
@@ -328,8 +273,8 @@ def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
|
||||
raise ValueError(f"User {new_user.uuid} already exists")
|
||||
if new_user.role_uuid not in _db.roles:
|
||||
raise ValueError(f"Role {new_user.role_uuid} not found")
|
||||
with _db.transaction("admin:create_user", ctx):
|
||||
_db.users[new_user.uuid] = new_user
|
||||
with _transaction("admin:create_user", ctx):
|
||||
new_user.store()
|
||||
|
||||
|
||||
def update_user_display_name(
|
||||
@@ -343,13 +288,103 @@ def update_user_display_name(
|
||||
The acting user should be logged via ctx.
|
||||
For self-service (user updating own name), pass user's ctx.
|
||||
For admin operations, pass admin's ctx.
|
||||
|
||||
If the user's preferred_username is currently None, this will auto-fill it
|
||||
with a slugified version of the display name (if unique and non-empty).
|
||||
"""
|
||||
if isinstance(uuid, str):
|
||||
uuid = UUID(uuid)
|
||||
if uuid not in _db.users:
|
||||
raise ValueError(f"User {uuid} not found")
|
||||
with _db.transaction("update_user_display_name", ctx):
|
||||
_db.users[uuid].display_name = display_name
|
||||
display_name = (display_name or "").strip()
|
||||
if not display_name:
|
||||
raise ValueError("Display name cannot be empty")
|
||||
user = _db.users[uuid]
|
||||
with _transaction("update_user_display_name", ctx):
|
||||
user.display_name = display_name
|
||||
# Auto-fill preferred_username if not already set
|
||||
if user.preferred_username is None:
|
||||
slug = slugify_name(display_name)
|
||||
if slug and not is_username_taken(slug, exclude_uuid=uuid):
|
||||
user.preferred_username = slug
|
||||
|
||||
|
||||
def update_user_info(
|
||||
uuid: UUID,
|
||||
*,
|
||||
display_name: str | object = _UNSET,
|
||||
theme: str | object = _UNSET,
|
||||
email: str | None | object = _UNSET,
|
||||
preferred_username: str | None | object = _UNSET,
|
||||
telephone: str | None | object = _UNSET,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Update user profile information.
|
||||
|
||||
Pass only the fields you want to update. Use None to clear optional fields.
|
||||
This does NOT auto-fill preferred_username - use update_user_display_name
|
||||
for the registration flow where auto-fill is desired.
|
||||
|
||||
Args:
|
||||
uuid: User UUID
|
||||
display_name: User display name (cannot be empty)
|
||||
theme: Theme preference ('' for auto, 'light', 'dark')
|
||||
email: Email address (None to clear)
|
||||
preferred_username: Username for OIDC claims (None to clear, must be unique)
|
||||
telephone: Phone number (None to clear)
|
||||
ctx: Session context for audit logging
|
||||
"""
|
||||
if isinstance(uuid, str):
|
||||
uuid = UUID(uuid)
|
||||
if uuid not in _db.users:
|
||||
raise ValueError(f"User {uuid} not found")
|
||||
|
||||
user = _db.users[uuid]
|
||||
|
||||
# Validate all fields before transaction
|
||||
if display_name is not _UNSET:
|
||||
display_name = (display_name or "").strip()
|
||||
if not display_name:
|
||||
raise ValueError("Display name cannot be empty")
|
||||
|
||||
if theme is not _UNSET:
|
||||
if theme not in ("", "light", "dark"):
|
||||
raise ValueError(f"Invalid theme: {theme}")
|
||||
|
||||
if email is not _UNSET and email is not None:
|
||||
email = (email or "").strip()
|
||||
if not email:
|
||||
email = None
|
||||
elif "@" not in email or len(email) > 254:
|
||||
raise ValueError("Invalid email format")
|
||||
|
||||
if preferred_username is not _UNSET and preferred_username is not None:
|
||||
preferred_username = (preferred_username or "").strip()
|
||||
if not preferred_username:
|
||||
raise ValueError("Preferred username cannot be empty (use None to clear)")
|
||||
if len(preferred_username) > 128:
|
||||
raise ValueError("preferred_username too long")
|
||||
if is_username_taken(preferred_username, exclude_uuid=uuid):
|
||||
raise ValueError("Username already taken")
|
||||
|
||||
if telephone is not _UNSET and telephone is not None:
|
||||
telephone = (telephone or "").strip()
|
||||
if not telephone:
|
||||
telephone = None
|
||||
elif len(telephone) > 32:
|
||||
raise ValueError("telephone too long")
|
||||
|
||||
with _transaction("update_user_info", ctx):
|
||||
if display_name is not _UNSET:
|
||||
user.display_name = display_name
|
||||
if theme is not _UNSET:
|
||||
user.theme = theme
|
||||
if email is not _UNSET:
|
||||
user.email = email
|
||||
if preferred_username is not _UNSET:
|
||||
user.preferred_username = preferred_username
|
||||
if telephone is not _UNSET:
|
||||
user.telephone = telephone
|
||||
|
||||
|
||||
def update_user_role(
|
||||
@@ -363,49 +398,16 @@ def update_user_role(
|
||||
raise ValueError(f"User {uuid} not found")
|
||||
if role_uuid not in _db.roles:
|
||||
raise ValueError(f"Role {role_uuid} not found")
|
||||
with _db.transaction("admin:update_user_role", ctx):
|
||||
with _transaction("admin:update_user_role", ctx):
|
||||
_db.users[uuid].role_uuid = role_uuid
|
||||
|
||||
|
||||
def update_user_role_in_organization(
|
||||
user_uuid: UUID,
|
||||
role_name: str,
|
||||
*,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Update user's role by role name within their current organization."""
|
||||
if user_uuid not in _db.users:
|
||||
raise ValueError(f"User {user_uuid} not found")
|
||||
user = _db.users[user_uuid]
|
||||
org = user.org
|
||||
# Find role by name in the same org
|
||||
new_role_uuid = None
|
||||
for r in org.roles:
|
||||
if r.display_name == role_name:
|
||||
new_role_uuid = r.uuid
|
||||
break
|
||||
if new_role_uuid is None:
|
||||
raise ValueError(f"Role '{role_name}' not found in organization")
|
||||
with _db.transaction("admin:update_user_role", ctx):
|
||||
_db.users[user_uuid].role_uuid = new_role_uuid
|
||||
|
||||
|
||||
def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Delete user and their credentials/sessions."""
|
||||
if uuid not in _db.users:
|
||||
raise ValueError(f"User {uuid} not found")
|
||||
user = _db.users[uuid]
|
||||
with _db.transaction("admin:delete_user", ctx):
|
||||
# Delete credentials
|
||||
for cred in user.credentials:
|
||||
del _db.credentials[cred.uuid]
|
||||
# Delete sessions
|
||||
for sess in user.sessions:
|
||||
del _db.sessions[sess.key]
|
||||
# Delete reset tokens
|
||||
for token in user.reset_tokens:
|
||||
del _db.reset_tokens[token.key]
|
||||
del _db.users[uuid]
|
||||
with _transaction("admin:delete_user", ctx):
|
||||
_db.users[uuid].delete()
|
||||
|
||||
|
||||
def create_credential(cred: Credential, *, ctx: SessionContext | None = None) -> None:
|
||||
@@ -414,8 +416,8 @@ def create_credential(cred: Credential, *, ctx: SessionContext | None = None) ->
|
||||
raise ValueError(f"Credential {cred.uuid} already exists")
|
||||
if cred.user_uuid not in _db.users:
|
||||
raise ValueError(f"User {cred.user_uuid} not found")
|
||||
with _db.transaction("create_credential", ctx):
|
||||
_db.credentials[cred.uuid] = cred
|
||||
with _transaction("create_credential", ctx):
|
||||
cred.store()
|
||||
|
||||
|
||||
def update_credential_sign_count(
|
||||
@@ -428,7 +430,7 @@ def update_credential_sign_count(
|
||||
"""Update credential sign count and last_used."""
|
||||
if uuid not in _db.credentials:
|
||||
raise ValueError(f"Credential {uuid} not found")
|
||||
with _db.transaction("update_credential_sign_count", ctx):
|
||||
with _transaction("update_credential_sign_count", ctx):
|
||||
_db.credentials[uuid].sign_count = sign_count
|
||||
if last_used:
|
||||
_db.credentials[uuid].last_used = last_used
|
||||
@@ -450,42 +452,8 @@ def delete_credential(
|
||||
if user_uuid is not None:
|
||||
if cred.user_uuid != user_uuid:
|
||||
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
|
||||
with _db.transaction("delete_credential", ctx):
|
||||
# Delete all sessions using this credential
|
||||
for sess in cred.sessions:
|
||||
print(sess, repr(sess.key))
|
||||
del _db.sessions[sess.key]
|
||||
del _db.credentials[uuid]
|
||||
|
||||
|
||||
def create_session(
|
||||
user_uuid: UUID,
|
||||
credential_uuid: UUID,
|
||||
host: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
expiry: datetime,
|
||||
*,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> str:
|
||||
"""Create a new session. Returns the session key."""
|
||||
if user_uuid not in _db.users:
|
||||
raise ValueError(f"User {user_uuid} not found")
|
||||
if credential_uuid not in _db.credentials:
|
||||
raise ValueError(f"Credential {credential_uuid} not found")
|
||||
session = Session.create(
|
||||
user=user_uuid,
|
||||
credential=credential_uuid,
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
expiry=expiry,
|
||||
)
|
||||
if session.key in _db.sessions:
|
||||
raise ValueError("Session already exists")
|
||||
with _db.transaction("create_session", ctx):
|
||||
_db.sessions[session.key] = session
|
||||
return session.key
|
||||
with _transaction("delete_credential", ctx):
|
||||
cred.delete()
|
||||
|
||||
|
||||
def update_session(
|
||||
@@ -493,14 +461,14 @@ def update_session(
|
||||
host: str | None = None,
|
||||
ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
expiry: datetime | None = None,
|
||||
validated: datetime | None = None,
|
||||
*,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Update session metadata."""
|
||||
if key not in _db.sessions:
|
||||
raise ValueError("Session not found")
|
||||
with _db.transaction("update_session", ctx):
|
||||
with _transaction("update_session", ctx):
|
||||
s = _db.sessions[key]
|
||||
if host is not None:
|
||||
s.host = host
|
||||
@@ -508,8 +476,8 @@ def update_session(
|
||||
s.ip = ip
|
||||
if user_agent is not None:
|
||||
s.user_agent = user_agent
|
||||
if expiry is not None:
|
||||
s.expiry = expiry
|
||||
if validated is not None:
|
||||
s.validated = validated
|
||||
|
||||
|
||||
def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None:
|
||||
@@ -517,17 +485,21 @@ def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None)
|
||||
update_session(key, host=host, ctx=ctx)
|
||||
|
||||
|
||||
def delete_session(key: str, *, ctx: SessionContext | None = None) -> None:
|
||||
def delete_session(
|
||||
key: str, *, ctx: SessionContext | None = None, action: str = "delete_session"
|
||||
) -> None:
|
||||
"""Delete a session.
|
||||
|
||||
The acting user should be logged via ctx.
|
||||
For user logout, pass ctx of the user's session.
|
||||
For user logout, pass ctx of the user's session and action="logout".
|
||||
For admin terminating a session, pass admin's ctx.
|
||||
"""
|
||||
if key not in _db.sessions:
|
||||
raise ValueError("Session not found")
|
||||
with _db.transaction("delete_session", ctx):
|
||||
del _db.sessions[key]
|
||||
|
||||
oidc_notify.schedule_notifications([key])
|
||||
with _transaction(action, ctx):
|
||||
_db.sessions[key].delete()
|
||||
|
||||
|
||||
def delete_sessions_for_user(
|
||||
@@ -542,64 +514,51 @@ def delete_sessions_for_user(
|
||||
user = _db.users.get(user_uuid)
|
||||
if not user:
|
||||
return
|
||||
with _db.transaction("admin:delete_sessions_for_user", ctx):
|
||||
|
||||
keys = [s.key for s in user.sessions]
|
||||
oidc_notify.schedule_notifications(keys)
|
||||
with _transaction("admin:delete_sessions_for_user", ctx):
|
||||
for sess in user.sessions:
|
||||
del _db.sessions[sess.key]
|
||||
sess.delete()
|
||||
|
||||
|
||||
def create_reset_token(
|
||||
passphrase: str,
|
||||
user_uuid: UUID,
|
||||
expiry: datetime,
|
||||
token_type: str,
|
||||
*,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Create a reset token from a passphrase.
|
||||
user: str | None = None,
|
||||
) -> str:
|
||||
"""Create a reset token and return the passphrase.
|
||||
|
||||
The acting user should be logged via ctx.
|
||||
For self-service (user creating own recovery link), pass user's ctx.
|
||||
For admin operations, pass admin's ctx.
|
||||
For system operations (bootstrap), pass neither to log no user.
|
||||
For API operations where ctx is not available but user is known, pass user.
|
||||
|
||||
Returns:
|
||||
The passphrase to give to the user.
|
||||
"""
|
||||
key = _reset_key(passphrase)
|
||||
if key in _db.reset_tokens:
|
||||
raise ValueError("Reset token already exists")
|
||||
if user_uuid not in _db.users:
|
||||
raise ValueError(f"User {user_uuid} not found")
|
||||
with _db.transaction("create_reset_token", ctx):
|
||||
_db.reset_tokens[key] = ResetToken(
|
||||
user_uuid=user_uuid, expiry=expiry, token_type=token_type
|
||||
)
|
||||
token, passphrase = ResetToken.create(
|
||||
user=user_uuid, expiry=expiry, token_type=token_type
|
||||
)
|
||||
if token.key in _db.reset_tokens:
|
||||
raise ValueError("Reset token already exists")
|
||||
with _transaction("create_reset_token", ctx, user=user):
|
||||
token.store()
|
||||
return passphrase
|
||||
|
||||
|
||||
def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Delete a reset token."""
|
||||
if key not in _db.reset_tokens:
|
||||
raise ValueError("Reset token not found")
|
||||
with _db.transaction("delete_reset_token", ctx):
|
||||
del _db.reset_tokens[key]
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Cleanup (called by background task)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cleanup_expired() -> int:
|
||||
"""Remove expired sessions and reset tokens. Returns count removed."""
|
||||
now = datetime.now(UTC)
|
||||
count = 0
|
||||
with _db.transaction("expiry"):
|
||||
expired_sessions = [k for k, s in _db.sessions.items() if s.expiry < now]
|
||||
for k in expired_sessions:
|
||||
del _db.sessions[k]
|
||||
count += 1
|
||||
expired_tokens = [k for k, t in _db.reset_tokens.items() if t.expiry < now]
|
||||
for k in expired_tokens:
|
||||
del _db.reset_tokens[k]
|
||||
count += 1
|
||||
return count
|
||||
with _transaction("delete_reset_token", ctx):
|
||||
_db.reset_tokens[key].delete()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -607,11 +566,6 @@ def cleanup_expired() -> int:
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_token() -> str:
|
||||
"""Generate a 16-character URL-safe session token."""
|
||||
return secrets.token_urlsafe(12)
|
||||
|
||||
|
||||
def login(
|
||||
user_uuid: UUID,
|
||||
credential_uuid: UUID,
|
||||
@@ -619,7 +573,7 @@ def login(
|
||||
host: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
expiry: datetime,
|
||||
duration: timedelta = SESSION_LIFETIME,
|
||||
) -> str:
|
||||
"""Update user/credential on login and create session in a single transaction.
|
||||
|
||||
@@ -639,25 +593,51 @@ def login(
|
||||
if credential_uuid not in _db.credentials:
|
||||
raise ValueError(f"Credential {credential_uuid} not found")
|
||||
|
||||
# Generate token and derive key
|
||||
token = secrets.token_urlsafe(12)
|
||||
|
||||
session = Session.create(
|
||||
user=user_uuid,
|
||||
credential=credential_uuid,
|
||||
key=hash_secret("cookie", token),
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
expiry=expiry,
|
||||
validated=now,
|
||||
)
|
||||
user_str = str(user_uuid)
|
||||
with _db.transaction("login", user=user_str):
|
||||
# Update user
|
||||
_db.users[user_uuid].last_seen = now
|
||||
_db.users[user_uuid].visits += 1
|
||||
with _transaction("login", user=user_str):
|
||||
session.store(now)
|
||||
# Update credential
|
||||
_db.credentials[credential_uuid].sign_count = sign_count
|
||||
_db.credentials[credential_uuid].last_used = now
|
||||
return token
|
||||
|
||||
|
||||
def oidc_login(
|
||||
session: Session,
|
||||
credential_uuid: UUID,
|
||||
sign_count: int,
|
||||
) -> None:
|
||||
"""Store an OIDC session and update credential in a single transaction.
|
||||
|
||||
The caller is responsible for generating the token, deriving the key,
|
||||
and creating the Session object. This function only handles the
|
||||
database transaction.
|
||||
|
||||
Updates:
|
||||
- user.last_seen, user.visits
|
||||
- credential.sign_count, credential.last_used
|
||||
Stores:
|
||||
- the provided session
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
user_str = str(session.user_uuid)
|
||||
with _transaction("oidc_login", user=user_str):
|
||||
session.store(now)
|
||||
# Update credential
|
||||
_db.credentials[credential_uuid].sign_count = sign_count
|
||||
_db.credentials[credential_uuid].last_used = now
|
||||
# Create session
|
||||
_db.sessions[session.key] = session
|
||||
return session.key
|
||||
|
||||
|
||||
def create_credential_session(
|
||||
@@ -681,145 +661,138 @@ def create_credential_session(
|
||||
"""
|
||||
|
||||
now = datetime.now(UTC)
|
||||
expiry = now + SESSION_LIFETIME
|
||||
|
||||
if user_uuid not in _db.users:
|
||||
raise ValueError(f"User {user_uuid} not found")
|
||||
|
||||
# Generate token and derive key
|
||||
token = secrets.token_urlsafe(12)
|
||||
key = hash_secret("cookie", token)
|
||||
|
||||
session = Session.create(
|
||||
user=user_uuid,
|
||||
credential=credential.uuid,
|
||||
key=key,
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
expiry=expiry,
|
||||
validated=now,
|
||||
)
|
||||
user_str = str(user_uuid)
|
||||
with _db.transaction("create_credential_session", user=user_str):
|
||||
with _transaction("create_credential_session", user=user_str):
|
||||
# Update display name if provided
|
||||
if display_name:
|
||||
_db.users[user_uuid].display_name = display_name
|
||||
|
||||
# Create credential
|
||||
_db.credentials[credential.uuid] = credential
|
||||
# Align credential timestamps with transaction time
|
||||
credential.created_at = now
|
||||
credential.last_used = now
|
||||
credential.last_verified = now
|
||||
|
||||
# Create session
|
||||
_db.sessions[session.key] = session
|
||||
# Create credential
|
||||
credential.store()
|
||||
|
||||
# Store session and record visit
|
||||
session.store(now)
|
||||
|
||||
# Delete reset token if provided
|
||||
if reset_key:
|
||||
if reset_key in _db.reset_tokens:
|
||||
del _db.reset_tokens[reset_key]
|
||||
return session.key
|
||||
reset_token = _db.reset_tokens.get(reset_key)
|
||||
if reset_token:
|
||||
reset_token.delete()
|
||||
return token
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Bootstrap (single transaction for initial system setup)
|
||||
# OIDC Provider operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def bootstrap(
|
||||
org_name: str = "Organization",
|
||||
admin_name: str = "Admin",
|
||||
reset_passphrase: str | None = None,
|
||||
reset_expiry: datetime | None = None,
|
||||
) -> str:
|
||||
"""Bootstrap the entire system in a single transaction.
|
||||
def create_oid_client(client: Client, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Create a new OIDC client."""
|
||||
if client.uuid in _db.oidc.clients:
|
||||
raise ValueError(f"OIDC client {client.uuid} already exists")
|
||||
with _transaction("admin:create_oid_client", ctx):
|
||||
_db.oidc.clients[client.uuid] = client
|
||||
|
||||
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
|
||||
|
||||
This is the only way to create a new database file (besides migrate).
|
||||
All data is created atomically - if any step fails, nothing is written.
|
||||
def update_oid_client(
|
||||
client_uuid: UUID,
|
||||
name: str | None = None,
|
||||
redirect_uris: list[str] | None = None,
|
||||
secret_hash: bytes | None = None,
|
||||
backchannel_logout_uri: str | None = _UNSET,
|
||||
*,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Update an OIDC client's name, redirect URIs, and/or secret."""
|
||||
if client_uuid not in _db.oidc.clients:
|
||||
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||
|
||||
Args:
|
||||
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)
|
||||
client = _db.oidc.clients[client_uuid]
|
||||
changes = {}
|
||||
|
||||
Returns:
|
||||
The reset passphrase for admin registration.
|
||||
"""
|
||||
if name is not None and name != client.name:
|
||||
changes["name"] = name
|
||||
if redirect_uris is not None and redirect_uris != client.redirect_uris:
|
||||
changes["redirect_uris"] = redirect_uris
|
||||
if secret_hash is not None and secret_hash != client.client_secret_hash:
|
||||
changes["client_secret_hash"] = secret_hash
|
||||
if (
|
||||
backchannel_logout_uri is not _UNSET
|
||||
and backchannel_logout_uri != client.backchannel_logout_uri
|
||||
):
|
||||
changes["backchannel_logout_uri"] = backchannel_logout_uri
|
||||
|
||||
# Check if system is already bootstrapped
|
||||
for p in _db.permissions.values():
|
||||
if p.scope == "auth:admin":
|
||||
raise ValueError(
|
||||
"System already bootstrapped (auth:admin permission exists)"
|
||||
)
|
||||
if not changes:
|
||||
return # No changes to make
|
||||
|
||||
# Generate UUIDs upfront
|
||||
perm_admin_uuid = uuid7.create()
|
||||
perm_org_admin_uuid = uuid7.create()
|
||||
org_uuid = uuid7.create()
|
||||
role_uuid = uuid7.create()
|
||||
user_uuid = uuid7.create()
|
||||
new_logout_uri = (
|
||||
backchannel_logout_uri
|
||||
if backchannel_logout_uri is not _UNSET
|
||||
else client.backchannel_logout_uri
|
||||
)
|
||||
|
||||
# Generate reset token components
|
||||
if reset_passphrase is None:
|
||||
reset_passphrase = generate_passphrase()
|
||||
if reset_expiry is None:
|
||||
from paskia.authsession import reset_expires # noqa: PLC0415
|
||||
|
||||
reset_expiry = reset_expires()
|
||||
reset_key = _reset_key(reset_passphrase)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
|
||||
with _db.transaction("bootstrap"):
|
||||
# Create auth:admin permission
|
||||
perm_admin = Permission(
|
||||
scope="auth:admin",
|
||||
display_name="Master Admin",
|
||||
orgs={org_uuid: True}, # Grant to org
|
||||
with _transaction("admin:update_oid_client", ctx):
|
||||
# Create updated client with new values
|
||||
updated_client = Client(
|
||||
client_secret_hash=secret_hash
|
||||
if secret_hash is not None
|
||||
else client.client_secret_hash,
|
||||
name=name if name is not None else client.name,
|
||||
redirect_uris=redirect_uris
|
||||
if redirect_uris is not None
|
||||
else client.redirect_uris,
|
||||
backchannel_logout_uri=new_logout_uri,
|
||||
)
|
||||
perm_admin.uuid = perm_admin_uuid
|
||||
_db.permissions[perm_admin_uuid] = perm_admin
|
||||
updated_client.uuid = client.uuid
|
||||
_db.oidc.clients[client_uuid] = updated_client
|
||||
|
||||
# Create auth:org:admin permission
|
||||
perm_org_admin = Permission(
|
||||
scope="auth:org:admin",
|
||||
display_name="Org Admin",
|
||||
orgs={org_uuid: True}, # Grant to org
|
||||
|
||||
def reset_oid_client_secret(
|
||||
client_uuid: UUID,
|
||||
new_secret_hash: bytes,
|
||||
*,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Reset an OIDC client's secret."""
|
||||
if client_uuid not in _db.oidc.clients:
|
||||
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||
client = _db.oidc.clients[client_uuid]
|
||||
with _transaction("admin:reset_oid_client_secret", ctx):
|
||||
updated = Client(
|
||||
client_secret_hash=new_secret_hash,
|
||||
name=client.name,
|
||||
redirect_uris=client.redirect_uris,
|
||||
backchannel_logout_uri=client.backchannel_logout_uri,
|
||||
)
|
||||
perm_org_admin.uuid = perm_org_admin_uuid
|
||||
_db.permissions[perm_org_admin_uuid] = perm_org_admin
|
||||
updated.uuid = client.uuid
|
||||
_db.oidc.clients[client_uuid] = updated
|
||||
|
||||
# Create organization
|
||||
new_org = Org.create(display_name=org_name)
|
||||
new_org.uuid = org_uuid
|
||||
_db.orgs[org_uuid] = new_org
|
||||
|
||||
# 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
|
||||
_db.roles[role_uuid] = admin_role
|
||||
|
||||
# Create admin user
|
||||
admin_user = User(
|
||||
display_name=admin_name,
|
||||
role_uuid=role_uuid,
|
||||
created_at=now,
|
||||
last_seen=None,
|
||||
visits=0,
|
||||
)
|
||||
admin_user.uuid = user_uuid
|
||||
_db.users[user_uuid] = admin_user
|
||||
|
||||
# Create reset token
|
||||
_db.reset_tokens[reset_key] = ResetToken(
|
||||
user_uuid=user_uuid,
|
||||
expiry=reset_expiry,
|
||||
token_type="admin bootstrap",
|
||||
)
|
||||
|
||||
return reset_passphrase
|
||||
def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Delete an OIDC client."""
|
||||
if client_uuid not in _db.oidc.clients:
|
||||
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||
with _transaction("admin:delete_oid_client", ctx):
|
||||
del _db.oidc.clients[client_uuid]
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def db_root_path(*, rp_id: str = "localhost") -> Path:
|
||||
"""Return the configured persistence root directory."""
|
||||
return Path(os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb"))
|
||||
|
||||
|
||||
def db_file_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
|
||||
"""Return the JSONL database file path under the persistence root."""
|
||||
root = db_root_path(rp_id=rp_id)
|
||||
|
||||
if root.is_file():
|
||||
_migrate_legacy_db_file(root)
|
||||
|
||||
if create_root:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return root / "main.db"
|
||||
|
||||
|
||||
def users_root_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
|
||||
"""Return the filesystem root for persisted user files."""
|
||||
root = db_root_path(rp_id=rp_id)
|
||||
|
||||
if root.is_file():
|
||||
_migrate_legacy_db_file(root)
|
||||
|
||||
if create_root:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return root / "users"
|
||||
|
||||
|
||||
def _migrate_legacy_db_file(legacy_path: Path) -> None:
|
||||
"""Upgrade a legacy single-file database path into a directory root."""
|
||||
temp_root = legacy_path.parent / f".{legacy_path.name}.migrating"
|
||||
shutil.rmtree(temp_root, ignore_errors=True)
|
||||
temp_root.unlink(missing_ok=True)
|
||||
|
||||
temp_root.mkdir(parents=True)
|
||||
legacy_path.replace(temp_root / "main.db")
|
||||
temp_root.rename(legacy_path)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user