Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea1ddbbe6f | ||
|
|
b091744665 | ||
|
|
2cf8799c75 | ||
|
|
a72349077c | ||
|
|
e102b8383b | ||
|
|
5aa8d021e6 | ||
|
|
3d5b0aa4bf | ||
|
|
29df169a67 | ||
|
|
97dc459bfb | ||
|
|
4d4b290cc8 | ||
|
|
0e1b9f529b | ||
|
|
0c3e0d3fa5 | ||
|
|
1782547b9e | ||
|
|
9976e05696 | ||
|
|
6124fa6c01 | ||
|
|
a6591a1fbb | ||
|
|
b9b1c995f9 | ||
|
|
4482a601f3 | ||
|
|
aa4b1bfd42 | ||
|
|
2ecf8433a1 | ||
|
|
db892365dc | ||
|
|
8d02c0f615 | ||
|
|
469d606ce5 | ||
|
|
547a6cd923 | ||
|
|
deabee3b5c | ||
|
|
fd1aa11409 | ||
|
|
ca1ea9d90b | ||
|
|
2dac0be77a | ||
|
|
f63c62d9ff | ||
|
|
768a4391cf | ||
|
|
f64876e73b | ||
|
|
b6a3cdd3a4 | ||
|
|
fd9a5afc1c | ||
|
|
8714fe9319 | ||
|
|
adbab88c86 | ||
|
|
5d9d2b794d | ||
|
|
eedbd4aaa4 | ||
|
|
15916047fa | ||
|
|
643d9bafab | ||
|
|
2699aaa472 | ||
|
|
5422845192 | ||
|
|
c1ccb048f0 | ||
|
|
3030122807 | ||
|
|
d4f8e97469 | ||
|
|
a62e8ddf1e | ||
|
|
2dca6b1eec | ||
|
|
4f50974222 | ||
|
|
c218ddad61 | ||
|
|
7247f7c584 | ||
|
|
af2834b4c0 | ||
|
|
ef66baff20 | ||
|
|
08d4607d65 | ||
|
|
1ca9e3ef58 | ||
|
|
575d3cb1fb | ||
|
|
a4ac19f54c | ||
|
|
11887d15b2 | ||
|
|
cefb9c3d92 | ||
|
|
5b9a3fc27f | ||
|
|
19a6c32cf2 | ||
|
|
eaa16abe2a | ||
|
|
01bc39a0e8 | ||
|
|
fa513940c7 | ||
|
|
f24aaa295d | ||
|
|
0af7aad28c | ||
|
|
24692fcfec | ||
|
|
43850c218f | ||
|
|
2f1578c4bc | ||
|
|
b4871c671f | ||
|
|
095768e07c | ||
|
|
2f77753354 | ||
|
|
ea871635e0 | ||
|
|
c3e4c18d5c | ||
|
|
a7c23b31e7 | ||
|
|
3f45024396 |
@@ -6,3 +6,4 @@ dist/
|
||||
passkey-auth.sqlite
|
||||
/passkey/frontend-build
|
||||
/test_*.py
|
||||
passkey/_version.py
|
||||
|
||||
@@ -1,28 +1,105 @@
|
||||
# PassKey Auth API Documentation
|
||||
|
||||
This document describes all API endpoints available in the PassKey Auth FastAPI application, that by default listens on `localhost:4401` ("for authentication required").
|
||||
This document lists the HTTP and WebSocket endpoints exposed by the PassKey Auth
|
||||
service and how they behave depending on whether a dedicated authentication host
|
||||
(`--auth-host` / environment `PASSKEY_AUTH_HOST`) is configured.
|
||||
|
||||
### HTTP Endpoints
|
||||
## Base Paths & Host Modes
|
||||
|
||||
GET /auth/ - Main authentication app
|
||||
GET /auth/admin/ - Admin app for managing organisations, users and permissions
|
||||
GET /auth/{reset_token} - Process password reset/share token
|
||||
POST /auth/api/user-info - Get authenticated user information
|
||||
POST /auth/api/logout - Logout and delete session
|
||||
POST /auth/api/set-session - Set session cookie from Authorization header
|
||||
POST /auth/api/create-link - Create device addition link
|
||||
DELETE /auth/api/credential/{uuid} - Delete specific credential
|
||||
POST /auth/api/validate - Session validation and renewal endpoint (fetch regularly)
|
||||
GET /auth/api/forward - Authentication validation for Caddy/Nginx
|
||||
- On success returns `204 No Content` with [user info](Headers.md)
|
||||
- Otherwise returns
|
||||
* `401 Unauthorized` - authentication required
|
||||
* `403 Forbidden` - missing required permissions
|
||||
* Serves the authentication app for a login or permission denied page
|
||||
- Does not renew session!
|
||||
Two deployment modes:
|
||||
|
||||
### WebAuthn/Passkey endpoints (WebSockets)
|
||||
1. Multi‑host (default – no `--auth-host` provided)
|
||||
- All endpoints are reachable on any host under the `/auth/` prefix.
|
||||
- A convenience root (`/`) also serves the main app.
|
||||
|
||||
WS /auth/ws/register - Register new user with passkey
|
||||
WS /auth/ws/add_credential - Add new credential for existing user
|
||||
WS /auth/ws/authenticate - Authenticate user with passkey
|
||||
2. Dedicated auth host (`--auth-host auth.example.com`)
|
||||
- The specified auth host serves the UI at the root (`/`, `/admin/`, reset tokens, etc.).
|
||||
- Other (non‑auth) hosts show a lightweight account summary at `/` or `/auth/`, while other UI routes still redirect to the auth host.
|
||||
- Restricted endpoints on non‑auth hosts return `404` instead of redirecting.
|
||||
|
||||
### Path Mapping When Auth Host Enabled
|
||||
|
||||
| Purpose | On Auth Host | On Other Hosts (incoming) | Action |
|
||||
|---------|--------------|---------------------------|--------|
|
||||
| Main UI | `/` | `/auth/` or `/` | Serve account summary SPA (no redirect) |
|
||||
| Admin UI root | `/admin/` | `/auth/admin/` or `/admin/` | Redirect -> auth host `/admin/` (strip `/auth`) |
|
||||
| Reset / device addition token | `/{token}` | `/auth/{token}` | Redirect -> auth host `/{token}` (strip `/auth`) |
|
||||
| Static assets | `/auth/assets/*` | `/auth/assets/*` | Served directly (no redirect) |
|
||||
| Unrestricted API | `/auth/api/...` | `/auth/api/...` | Served directly |
|
||||
| Restricted API (admin,user,ws namespaces) | `/auth/api/{admin|user|ws}*` | same path | 404 on non‑auth hosts |
|
||||
| WebSocket (register/auth) | `/auth/ws/*` | `/auth/ws/*` | 404 on non‑auth hosts |
|
||||
|
||||
Notes:
|
||||
- “Strip `/auth`” means only when the path starts with that exact segment.
|
||||
- A reset token is a single path segment validated by server logic; malformed tokens 404.
|
||||
- Method and body are preserved for UI redirects (307 Temporary Redirect).
|
||||
|
||||
## HTTP UI Endpoints
|
||||
|
||||
| Method | Path (multi‑host) | Path (auth host) | Description |
|
||||
|--------|-------------------|------------------|-------------|
|
||||
| GET | `/auth/` | `/` | Main authentication SPA (non-auth hosts show an account summary view) |
|
||||
| GET | `/auth/admin/` | `/admin/` | Admin SPA root |
|
||||
| GET | `/auth/{reset_token}` | `/{reset_token}` | Reset / device addition SPA (token validated) |
|
||||
|
||||
## Core API (Unrestricted – available on all hosts)
|
||||
|
||||
Always under `/auth/api/` (even on auth host):
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/auth/restricted/` | Authentication UI for iframe embedding (supports `?mode=login` or `?mode=reauth`) |
|
||||
|--------|------|-------------|
|
||||
| POST | `/auth/api/validate` | Validate & (conditionally) renew session |
|
||||
| GET | `/auth/api/forward` | Auth proxy endpoint for reverse proxies (204 or 4xx) |
|
||||
| POST | `/auth/api/set-session` | Set cookie from Bearer token |
|
||||
| POST | `/auth/api/logout` | Logout current session |
|
||||
| POST | `/auth/api/user-info` | Authenticated user + context info (also handles reset tokens) |
|
||||
| POST | `/auth/api/create-link` | Create a device addition link (reset token) |
|
||||
| DELETE | `/auth/api/credential/{uuid}` | Delete user credential |
|
||||
| DELETE | `/auth/api/session/{session_id}` | Terminate a specific session |
|
||||
| POST | `/auth/api/user/logout-all` | Terminate all sessions for the user |
|
||||
| PUT | `/auth/api/user/display-name` | Update display name |
|
||||
|
||||
## Restricted API Namespaces
|
||||
|
||||
When `--auth-host` is set, requests to these paths on non‑auth hosts return 404:
|
||||
|
||||
| Namespace | Examples |
|
||||
|-----------|----------|
|
||||
| `/auth/api/admin` | `/auth/api/admin/orgs`, `/auth/api/admin/orgs/{uuid}` ... |
|
||||
| `/auth/api/user` | Segment prefix – includes `/auth/api/user/...` endpoints (logout-all, display-name, session, credential) |
|
||||
| `/auth/api/ws` | (Reserved / future) |
|
||||
|
||||
## WebSockets (Passkey)
|
||||
|
||||
| Path | Description | Host Mode Behavior |
|
||||
|------|-------------|--------------------|
|
||||
| `/auth/ws/register` | Register new credential (new or existing user) | 404 on non‑auth hosts when auth host configured |
|
||||
| `/auth/ws/authenticate` | Authenticate user & issue session | 404 on non‑auth hosts when auth host configured |
|
||||
|
||||
## Redirection & Status Codes
|
||||
|
||||
| Scenario | Response |
|
||||
|----------|----------|
|
||||
| UI path on non‑auth host (auth host configured) | 307 redirect to auth host; `/auth` prefix stripped |
|
||||
| Reset token UI path on non‑auth host | 307 redirect (token preserved) |
|
||||
| Restricted API on non‑auth host | 404 |
|
||||
| Unrestricted API on any host | Normal response |
|
||||
| No auth host configured | All hosts behave like multi-host mode (no redirects; everything accessible) |
|
||||
|
||||
## Headers for /auth/api/forward
|
||||
See `Headers.md` for details of headers returned on success (204).
|
||||
|
||||
## Notes for Integrators
|
||||
1. Always use absolute `/auth/api/...` paths for programmatic requests (they do not move when an auth host is introduced).
|
||||
2. Bookmark / deep links to UI should resolve correctly after redirection if users access via a non-auth application host.
|
||||
3. Treat 404 from restricted namespaces on non-auth hosts as a signal to direct users to the central auth site.
|
||||
|
||||
## Environment & CLI Summary
|
||||
| Option | Effect |
|
||||
|--------|--------|
|
||||
| `--auth-host` / `PASSKEY_AUTH_HOST` | Enables dedicated host mode, root-mounts UI there, restricts certain namespaces elsewhere |
|
||||
|
||||
---
|
||||
This document reflects current behavior of the middleware-based host routing logic.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
localhost {
|
||||
# Forwards API by caddy, bypassing the Vite dev proxy
|
||||
# Avoids bug https://github.com/oven-sh/bun/issues/9882
|
||||
handle /api/* {
|
||||
reverse_proxy :4402 # directly to backend
|
||||
}
|
||||
handle {
|
||||
reverse_proxy :4403 # vite dev server
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Test artifacts
|
||||
test-data/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Playwright
|
||||
.playwright/
|
||||
|
||||
# Bun
|
||||
bun.lockb
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
# PasskeyAuth E2E Tests
|
||||
|
||||
End-to-end tests for PasskeyAuth using [Playwright](https://playwright.dev/) with Chrome's **Virtual Authenticator**.
|
||||
|
||||
## Overview
|
||||
|
||||
These tests exercise the complete WebAuthn/passkey authentication flow without requiring physical hardware. Chrome's DevTools Protocol provides a virtual authenticator that can:
|
||||
|
||||
- Generate passkey credentials
|
||||
- Sign authentication challenges
|
||||
- Store resident keys (discoverable credentials)
|
||||
- Simulate user verification (biometrics/PIN)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- Python with `uv` (for running the backend server)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd e2e
|
||||
npm install
|
||||
npm run install:browsers
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Basic Test Run
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Start a fresh PasskeyAuth server with a test database
|
||||
2. Run all E2E tests against it
|
||||
3. Clean up the server when done
|
||||
|
||||
### Interactive Mode
|
||||
|
||||
```bash
|
||||
npm run test:ui
|
||||
```
|
||||
|
||||
Opens Playwright's UI mode for interactive test debugging.
|
||||
|
||||
### Headed Mode
|
||||
|
||||
```bash
|
||||
npm run test:headed
|
||||
```
|
||||
|
||||
Runs tests with a visible browser window.
|
||||
|
||||
### Debug Mode
|
||||
|
||||
```bash
|
||||
npm run test:debug
|
||||
```
|
||||
|
||||
Runs tests with Playwright Inspector for step-by-step debugging.
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
e2e/
|
||||
├── playwright.config.ts # Playwright configuration
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── test-data/ # Test database (created at runtime)
|
||||
│ └── test.sqlite
|
||||
└── tests/
|
||||
├── global-setup.ts # Creates fresh DB, captures reset token
|
||||
├── global-teardown.ts # Cleanup
|
||||
├── passkey.spec.ts # Main E2E tests
|
||||
└── fixtures/
|
||||
├── virtual-authenticator.ts # Virtual authenticator setup
|
||||
└── passkey-helpers.ts # WebSocket helpers
|
||||
```
|
||||
|
||||
## What's Tested
|
||||
|
||||
### Registration Flow
|
||||
- Bootstrap admin user registration via reset token
|
||||
- WebSocket challenge-response with virtual authenticator
|
||||
- Session token creation and validation
|
||||
|
||||
### Authentication Flow
|
||||
- Passkey authentication via WebSocket
|
||||
- Credential verification
|
||||
- Session management
|
||||
|
||||
### Session Management
|
||||
- Token validation (`/auth/api/validate`)
|
||||
- User info retrieval (`/auth/api/user-info`)
|
||||
- Logout (`/auth/api/logout`)
|
||||
- Invalid/missing token rejection
|
||||
|
||||
## How Virtual Authenticator Works
|
||||
|
||||
The tests use Chrome DevTools Protocol (CDP) to create a virtual authenticator:
|
||||
|
||||
```typescript
|
||||
const cdpSession = await page.context().newCDPSession(page)
|
||||
await cdpSession.send('WebAuthn.enable')
|
||||
await cdpSession.send('WebAuthn.addVirtualAuthenticator', {
|
||||
options: {
|
||||
protocol: 'ctap2',
|
||||
transport: 'internal',
|
||||
hasResidentKey: true,
|
||||
hasUserVerification: true,
|
||||
isUserVerified: true,
|
||||
automaticPresenceSimulation: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
This creates an in-browser authenticator that:
|
||||
- Automatically responds to WebAuthn prompts
|
||||
- Stores credentials persistently during the test session
|
||||
- Simulates user verification without actual biometric input
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `BASE_URL` | Server URL | `http://localhost:4401` |
|
||||
| `CI` | CI environment flag | - |
|
||||
| `CLEANUP_TEST_DB` | Remove test DB after run | `false` |
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **Chromium only**: Virtual authenticator is a Chrome DevTools feature
|
||||
2. **No cross-origin**: Tests run on localhost; production-like origins need additional setup
|
||||
3. **Single user per run**: Bootstrap creates one admin user; additional users need admin API
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
1. **Check test database**: `e2e/test-data/test.sqlite` persists after tests
|
||||
2. **View server output**: Global setup echoes server bootstrap to console
|
||||
3. **Use trace viewer**: `npx playwright show-trace` on failure traces
|
||||
|
||||
## CI Integration
|
||||
|
||||
The tests are designed for CI environments:
|
||||
|
||||
```yaml
|
||||
- name: Run E2E Tests
|
||||
run: |
|
||||
cd e2e
|
||||
npm ci
|
||||
npm run install:browsers
|
||||
npm test
|
||||
env:
|
||||
CI: true
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "passkey-auth-e2e",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "E2E tests for PasskeyAuth 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",
|
||||
"report": "bunx playwright show-report",
|
||||
"install:browsers": "bunx playwright install chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@types/bun": "^1.3.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Playwright configuration for PasskeyAuth E2E tests.
|
||||
* Uses Chrome's Virtual Authenticator for automated passkey testing.
|
||||
*
|
||||
* Run with: bun run test
|
||||
*/
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: false, // Run tests sequentially for passkey state consistency
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1, // Single worker for database state consistency
|
||||
reporter: [
|
||||
['html', { open: 'never' }],
|
||||
['list']
|
||||
],
|
||||
|
||||
// Global setup/teardown for test database and server
|
||||
globalSetup: './tests/global-setup.ts',
|
||||
globalTeardown: './tests/global-teardown.ts',
|
||||
|
||||
use: {
|
||||
// Base URL for the passkey-auth server
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:4401',
|
||||
|
||||
// Collect trace on failure for debugging
|
||||
trace: 'on-first-retry',
|
||||
|
||||
// Screenshot on failure
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
// Chrome-specific settings for virtual authenticator
|
||||
launchOptions: {
|
||||
args: [
|
||||
'--enable-features=WebAuthenticationEnterpriseAttestation',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
import { type Page } from '@playwright/test'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/**
|
||||
* WebSocket helpers for passkey registration and authentication.
|
||||
* These functions mirror the frontend's passkey.js but work in a Playwright context.
|
||||
*/
|
||||
|
||||
export interface RegistrationResult {
|
||||
user_uuid: string
|
||||
credential_uuid: string
|
||||
session_token: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface AuthenticationResult {
|
||||
user_uuid: string
|
||||
session_token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bootstrap reset token from the test state file.
|
||||
*/
|
||||
export function getBootstrapResetToken(): string | undefined {
|
||||
const stateFile = join(__dirname, '..', '..', 'test-data', 'test-state.json')
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
return state.resetToken
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform passkey registration via WebSocket.
|
||||
* This runs in the browser context using the virtual authenticator.
|
||||
*/
|
||||
export async function registerPasskey(
|
||||
page: Page,
|
||||
baseUrl: string,
|
||||
options: { resetToken?: string; displayName?: string } = {}
|
||||
): Promise<RegistrationResult> {
|
||||
return 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[] = []
|
||||
if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`)
|
||||
if (displayName) params.push(`name=${encodeURIComponent(displayName)}`)
|
||||
if (params.length) wsUrl += `?${params.join('&')}`
|
||||
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected for registration')
|
||||
}
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
// Check for error response
|
||||
if (data.detail) {
|
||||
ws.close()
|
||||
reject(new Error(data.detail))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is the final success response
|
||||
if (data.session_token) {
|
||||
ws.close()
|
||||
resolve(data)
|
||||
return
|
||||
}
|
||||
|
||||
// This should be the registration options from server
|
||||
// Use the native WebAuthn API with the virtual authenticator
|
||||
try {
|
||||
// Convert base64url challenge to ArrayBuffer
|
||||
const challenge = Uint8Array.from(atob(data.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
||||
|
||||
// Build the credential creation options
|
||||
const publicKeyCredentialCreationOptions: CredentialCreationOptions = {
|
||||
publicKey: {
|
||||
challenge: challenge,
|
||||
rp: {
|
||||
name: data.rp.name,
|
||||
id: data.rp.id,
|
||||
},
|
||||
user: {
|
||||
id: Uint8Array.from(atob(data.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
||||
name: data.user.name,
|
||||
displayName: data.user.displayName,
|
||||
},
|
||||
pubKeyCredParams: data.pubKeyCredParams,
|
||||
authenticatorSelection: data.authenticatorSelection,
|
||||
timeout: data.timeout,
|
||||
attestation: data.attestation,
|
||||
excludeCredentials: data.excludeCredentials?.map((cred: any) => ({
|
||||
...cred,
|
||||
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
||||
})) || [],
|
||||
}
|
||||
}
|
||||
|
||||
// Create the credential using native WebAuthn API (virtual authenticator handles it)
|
||||
const credential = await navigator.credentials.create(publicKeyCredentialCreationOptions) as PublicKeyCredential
|
||||
|
||||
if (!credential) {
|
||||
throw new Error('Failed to create credential')
|
||||
}
|
||||
|
||||
const response = credential.response as AuthenticatorAttestationResponse
|
||||
|
||||
// Convert response to JSON format expected by server
|
||||
const registrationResponse = {
|
||||
id: credential.id,
|
||||
rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
||||
response: {
|
||||
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(response.clientDataJSON))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
||||
attestationObject: btoa(String.fromCharCode(...new Uint8Array(response.attestationObject))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
||||
transports: response.getTransports?.() || [],
|
||||
},
|
||||
type: credential.type,
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
authenticatorAttachment: (credential as any).authenticatorAttachment,
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify(registrationResponse))
|
||||
} catch (error: any) {
|
||||
ws.close()
|
||||
reject(new Error(error.message || 'Registration failed'))
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
reject(new Error('WebSocket error during registration'))
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!event.wasClean && event.code !== 1000) {
|
||||
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
||||
}
|
||||
}
|
||||
})
|
||||
}, { baseUrl, resetToken: options.resetToken, displayName: options.displayName })
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform passkey authentication via WebSocket.
|
||||
* This runs in the browser context using the virtual authenticator.
|
||||
*/
|
||||
export async function authenticatePasskey(
|
||||
page: Page,
|
||||
baseUrl: string
|
||||
): Promise<AuthenticationResult> {
|
||||
return 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)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected for authentication')
|
||||
}
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
// Check for error response
|
||||
if (data.detail) {
|
||||
ws.close()
|
||||
reject(new Error(data.detail))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is the final success response
|
||||
if (data.session_token) {
|
||||
ws.close()
|
||||
resolve(data)
|
||||
return
|
||||
}
|
||||
|
||||
// This should be the authentication options from server
|
||||
try {
|
||||
// Convert base64url challenge to ArrayBuffer
|
||||
const challenge = Uint8Array.from(atob(data.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
||||
|
||||
// Build the credential request options
|
||||
const publicKeyCredentialRequestOptions: CredentialRequestOptions = {
|
||||
publicKey: {
|
||||
challenge: challenge,
|
||||
rpId: data.rpId,
|
||||
timeout: data.timeout,
|
||||
userVerification: data.userVerification,
|
||||
allowCredentials: data.allowCredentials?.map((cred: any) => ({
|
||||
type: cred.type,
|
||||
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
||||
transports: cred.transports,
|
||||
})) || [],
|
||||
}
|
||||
}
|
||||
|
||||
// Get the credential using native WebAuthn API (virtual authenticator handles it)
|
||||
const credential = await navigator.credentials.get(publicKeyCredentialRequestOptions) as PublicKeyCredential
|
||||
|
||||
if (!credential) {
|
||||
throw new Error('Failed to get credential')
|
||||
}
|
||||
|
||||
const response = credential.response as AuthenticatorAssertionResponse
|
||||
|
||||
// Convert response to JSON format expected by server
|
||||
const authenticationResponse = {
|
||||
id: credential.id,
|
||||
rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
||||
response: {
|
||||
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(response.clientDataJSON))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
||||
authenticatorData: btoa(String.fromCharCode(...new Uint8Array(response.authenticatorData))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
||||
signature: btoa(String.fromCharCode(...new Uint8Array(response.signature))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
||||
userHandle: response.userHandle ? btoa(String.fromCharCode(...new Uint8Array(response.userHandle))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') : null,
|
||||
},
|
||||
type: credential.type,
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
authenticatorAttachment: (credential as any).authenticatorAttachment,
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify(authenticationResponse))
|
||||
} catch (error: any) {
|
||||
ws.close()
|
||||
reject(new Error(error.message || 'Authentication failed'))
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
reject(new Error('WebSocket error during authentication'))
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!event.wasClean && event.code !== 1000) {
|
||||
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
||||
}
|
||||
}
|
||||
})
|
||||
}, { baseUrl })
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a session token via the API.
|
||||
*/
|
||||
export async function validateSession(
|
||||
page: Page,
|
||||
baseUrl: string,
|
||||
sessionToken: string
|
||||
): Promise<{ valid: boolean; user_uuid: string; renewed: boolean }> {
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||||
headers: {
|
||||
'Cookie': `__Host-auth=${sessionToken}`,
|
||||
},
|
||||
})
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user info via the API.
|
||||
*/
|
||||
export async function getUserInfo(
|
||||
page: Page,
|
||||
baseUrl: string,
|
||||
sessionToken: string
|
||||
): Promise<any> {
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/user-info`, {
|
||||
headers: {
|
||||
'Cookie': `__Host-auth=${sessionToken}`,
|
||||
},
|
||||
})
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout via the API.
|
||||
*/
|
||||
export async function logout(
|
||||
page: Page,
|
||||
baseUrl: string,
|
||||
sessionToken: string
|
||||
): Promise<void> {
|
||||
await page.request.post(`${baseUrl}/auth/api/logout`, {
|
||||
headers: {
|
||||
'Cookie': `__Host-auth=${sessionToken}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a device link for adding a new credential to an existing user.
|
||||
*/
|
||||
export async function createDeviceLink(
|
||||
page: Page,
|
||||
baseUrl: string,
|
||||
sessionToken: string
|
||||
): Promise<{ url: string; token: string }> {
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/user/create-link`, {
|
||||
headers: {
|
||||
'Cookie': `__Host-auth=${sessionToken}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
// Extract token from URL (last path segment)
|
||||
const url = new URL(data.url)
|
||||
const token = url.pathname.split('/').pop() || ''
|
||||
return { url: data.url, token }
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { test as base, expect, type CDPSession, type Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Virtual Authenticator configuration for WebAuthn testing.
|
||||
* Uses Chrome DevTools Protocol to create a software authenticator.
|
||||
*/
|
||||
export interface VirtualAuthenticatorOptions {
|
||||
protocol?: 'ctap1/u2f' | 'ctap2'
|
||||
transport?: 'usb' | 'nfc' | 'ble' | 'internal'
|
||||
hasResidentKey?: boolean
|
||||
hasUserVerification?: boolean
|
||||
isUserVerified?: boolean
|
||||
automaticPresenceSimulation?: boolean
|
||||
}
|
||||
|
||||
export interface VirtualAuthenticator {
|
||||
authenticatorId: string
|
||||
cdpSession: CDPSession
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a virtual authenticator using Chrome DevTools Protocol.
|
||||
* This allows fully automated passkey registration and authentication.
|
||||
*/
|
||||
export async function createVirtualAuthenticator(
|
||||
page: Page,
|
||||
options: VirtualAuthenticatorOptions = {}
|
||||
): Promise<VirtualAuthenticator> {
|
||||
const cdpSession = await page.context().newCDPSession(page)
|
||||
|
||||
// Enable WebAuthn in CDP
|
||||
await cdpSession.send('WebAuthn.enable', {
|
||||
enableUI: false, // Suppress any UI prompts
|
||||
})
|
||||
|
||||
// Create the virtual authenticator with resident key support
|
||||
const { authenticatorId } = await cdpSession.send('WebAuthn.addVirtualAuthenticator', {
|
||||
options: {
|
||||
protocol: options.protocol ?? 'ctap2',
|
||||
transport: options.transport ?? 'internal',
|
||||
hasResidentKey: options.hasResidentKey ?? true,
|
||||
hasUserVerification: options.hasUserVerification ?? true,
|
||||
isUserVerified: options.isUserVerified ?? true,
|
||||
automaticPresenceSimulation: options.automaticPresenceSimulation ?? true,
|
||||
},
|
||||
})
|
||||
|
||||
return { authenticatorId, cdpSession }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a virtual authenticator.
|
||||
*/
|
||||
export async function removeVirtualAuthenticator(
|
||||
authenticator: VirtualAuthenticator
|
||||
): Promise<void> {
|
||||
await authenticator.cdpSession.send('WebAuthn.removeVirtualAuthenticator', {
|
||||
authenticatorId: authenticator.authenticatorId,
|
||||
})
|
||||
await authenticator.cdpSession.send('WebAuthn.disable')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all credentials stored in a virtual authenticator.
|
||||
*/
|
||||
export async function getCredentials(
|
||||
authenticator: VirtualAuthenticator
|
||||
): Promise<any[]> {
|
||||
const result = await authenticator.cdpSession.send('WebAuthn.getCredentials', {
|
||||
authenticatorId: authenticator.authenticatorId,
|
||||
})
|
||||
return result.credentials
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended test fixture with virtual authenticator support.
|
||||
*/
|
||||
export const test = base.extend<{
|
||||
virtualAuthenticator: VirtualAuthenticator
|
||||
}>({
|
||||
virtualAuthenticator: async ({ page }, use) => {
|
||||
// Create virtual authenticator before test
|
||||
const authenticator = await createVirtualAuthenticator(page)
|
||||
|
||||
// Run the test
|
||||
await use(authenticator)
|
||||
|
||||
// Cleanup after test
|
||||
await removeVirtualAuthenticator(authenticator)
|
||||
},
|
||||
})
|
||||
|
||||
export { expect }
|
||||
@@ -0,0 +1,113 @@
|
||||
import { spawn } from 'child_process'
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const testDataDir = join(__dirname, '..', 'test-data')
|
||||
const stateFile = join(testDataDir, 'test-state.json')
|
||||
const dbPath = join(testDataDir, 'test.sqlite')
|
||||
|
||||
interface TestState {
|
||||
resetToken?: string
|
||||
serverPid?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Global setup for E2E tests.
|
||||
*
|
||||
* This creates a fresh test database and starts the server,
|
||||
* capturing the bootstrap reset token for initial user registration.
|
||||
*/
|
||||
export default async function globalSetup() {
|
||||
console.log('\n🔧 Setting up E2E test environment...\n')
|
||||
|
||||
// Create test data directory
|
||||
if (!existsSync(testDataDir)) {
|
||||
mkdirSync(testDataDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Remove old database for clean state
|
||||
if (existsSync(dbPath)) {
|
||||
console.log(' Removing old test database...')
|
||||
rmSync(dbPath)
|
||||
}
|
||||
|
||||
// Remove any wal/shm files too
|
||||
for (const ext of ['-wal', '-shm']) {
|
||||
const file = dbPath + ext
|
||||
if (existsSync(file)) rmSync(file)
|
||||
}
|
||||
|
||||
console.log(' Starting server with fresh database...')
|
||||
|
||||
const state: TestState = {}
|
||||
|
||||
// Start the server using Node's spawn
|
||||
const serverProcess = spawn('uv', [
|
||||
'run', 'passkey-auth', 'serve', ':4401',
|
||||
'--rp-id', 'localhost',
|
||||
'--origin', 'http://localhost:4401'
|
||||
], {
|
||||
cwd: testDataDir, // Run from test-data so DB is created there
|
||||
env: {
|
||||
...process.env,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
state.serverPid = serverProcess.pid
|
||||
|
||||
// Capture output to find reset token
|
||||
const resetTokenPromise = new Promise<string>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('Timed out waiting for server bootstrap (30s)'))
|
||||
}, 30000)
|
||||
|
||||
let output = ''
|
||||
|
||||
const handleData = (data: Buffer) => {
|
||||
const text = data.toString()
|
||||
output += text
|
||||
process.stdout.write(text) // Echo to console
|
||||
|
||||
// Look for the reset token URL in the output
|
||||
// Format: http://localhost:4401/auth/{token} where token is word.word.word.word.word (dot separated)
|
||||
const match = output.match(/http:\/\/localhost:\d+\/auth\/([a-z]+(?:\.[a-z]+)+)/)
|
||||
if (match) {
|
||||
clearTimeout(timeout)
|
||||
// Wait a bit for server to fully start
|
||||
setTimeout(() => resolve(match[1]), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
serverProcess.stdout?.on('data', handleData)
|
||||
serverProcess.stderr?.on('data', handleData)
|
||||
|
||||
serverProcess.on('error', (err) => {
|
||||
clearTimeout(timeout)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
serverProcess.on('exit', (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error(`Server exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
state.resetToken = await resetTokenPromise
|
||||
console.log(`\n ✅ Captured reset token: ${state.resetToken}\n`)
|
||||
} catch (err) {
|
||||
console.error('Failed to capture reset token:', err)
|
||||
serverProcess.kill()
|
||||
throw err
|
||||
}
|
||||
|
||||
// Save state for tests
|
||||
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
||||
|
||||
console.log(' ✅ E2E test environment ready\n')
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, rmSync, readFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const testDataDir = join(__dirname, '..', 'test-data')
|
||||
const stateFile = join(testDataDir, 'test-state.json')
|
||||
|
||||
interface TestState {
|
||||
resetToken?: string
|
||||
serverPid?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Global teardown for E2E tests.
|
||||
*
|
||||
* This cleans up the test server and optionally removes the test database.
|
||||
*/
|
||||
export default async function globalTeardown() {
|
||||
console.log('\n🧹 Cleaning up E2E test environment...\n')
|
||||
|
||||
// Read state file to get server PID
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const state: TestState = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
|
||||
if (state.serverPid) {
|
||||
console.log(` Stopping server (PID: ${state.serverPid})...`)
|
||||
try {
|
||||
process.kill(state.serverPid, 'SIGTERM')
|
||||
// Wait a moment for graceful shutdown
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
} catch (err: any) {
|
||||
// Process may already be dead
|
||||
if (err.code !== 'ESRCH') {
|
||||
console.warn(` Warning: Could not kill server: ${err.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(' Warning: Could not read state file')
|
||||
}
|
||||
|
||||
// Clean up state file
|
||||
rmSync(stateFile, { force: true })
|
||||
}
|
||||
|
||||
// Optionally clean up test database (keep it for debugging by default)
|
||||
if (process.env.CLEANUP_TEST_DB === 'true') {
|
||||
const dbPath = join(testDataDir, 'test.sqlite')
|
||||
if (existsSync(dbPath)) {
|
||||
console.log(' Removing test database...')
|
||||
rmSync(dbPath)
|
||||
}
|
||||
// Remove wal/shm files too
|
||||
for (const ext of ['-wal', '-shm']) {
|
||||
const file = dbPath + ext
|
||||
if (existsSync(file)) rmSync(file)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(' ✅ Cleanup complete\n')
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { test, expect } from './fixtures/virtual-authenticator'
|
||||
import {
|
||||
registerPasskey,
|
||||
authenticatePasskey,
|
||||
validateSession,
|
||||
getUserInfo,
|
||||
logout,
|
||||
getBootstrapResetToken,
|
||||
createDeviceLink,
|
||||
} from './fixtures/passkey-helpers'
|
||||
|
||||
/**
|
||||
* E2E tests for PasskeyAuth using Chrome's Virtual Authenticator.
|
||||
*
|
||||
* These tests exercise the complete WebAuthn flow:
|
||||
* 1. Registration via WebSocket using bootstrap reset token
|
||||
* 2. Authentication via WebSocket
|
||||
* 3. Session validation
|
||||
* 4. User info retrieval
|
||||
* 5. Logout
|
||||
*
|
||||
* The virtual authenticator simulates a hardware passkey device,
|
||||
* allowing fully automated testing without physical hardware.
|
||||
*/
|
||||
|
||||
test.describe('Passkey Authentication E2E', () => {
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:4401'
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
// Shared state across tests in this describe block
|
||||
let sessionToken: string
|
||||
let userUuid: string
|
||||
let credentialUuid: string
|
||||
let resetToken: string | undefined
|
||||
|
||||
test.beforeAll(() => {
|
||||
// Get the bootstrap reset token from global setup
|
||||
resetToken = getBootstrapResetToken()
|
||||
if (!resetToken) {
|
||||
console.warn('⚠️ No reset token found - registration test may fail')
|
||||
} else {
|
||||
console.log(`📝 Using reset token: ${resetToken}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('should load the auth page', async ({ page }) => {
|
||||
// Navigate to auth page to establish origin for WebAuthn
|
||||
await page.goto('/auth/')
|
||||
await expect(page).toHaveTitle(/.*/)
|
||||
|
||||
// Page should load - 401 errors are expected since user is not logged in
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Just verify the page loaded without JS errors (network 401s are OK)
|
||||
console.log('✓ Auth page loaded successfully')
|
||||
})
|
||||
|
||||
test('should register admin passkey via WebSocket using reset token', async ({ page, virtualAuthenticator }) => {
|
||||
test.skip(!resetToken, 'No reset token available from bootstrap')
|
||||
|
||||
// Must visit the page first to establish origin
|
||||
await page.goto('/auth/')
|
||||
|
||||
// Perform registration via WebSocket with virtual authenticator
|
||||
// Using the bootstrap reset token for the admin user
|
||||
const result = await registerPasskey(page, baseUrl, {
|
||||
resetToken: resetToken,
|
||||
displayName: 'Admin User',
|
||||
})
|
||||
|
||||
// Verify registration result
|
||||
expect(result.session_token).toBeDefined()
|
||||
expect(result.session_token).toHaveLength(16)
|
||||
expect(result.user_uuid).toBeDefined()
|
||||
expect(result.credential_uuid).toBeDefined()
|
||||
expect(result.message).toContain('successfully')
|
||||
|
||||
// Store for subsequent tests
|
||||
sessionToken = result.session_token
|
||||
userUuid = result.user_uuid
|
||||
credentialUuid = result.credential_uuid
|
||||
|
||||
console.log(`✓ Registered user: ${userUuid}`)
|
||||
console.log(`✓ Credential: ${credentialUuid}`)
|
||||
console.log(`✓ Session token: ${sessionToken.substring(0, 4)}...`)
|
||||
})
|
||||
|
||||
test('should validate the session token', async ({ page }) => {
|
||||
// Skip if registration didn't run
|
||||
test.skip(!sessionToken, 'Requires successful registration')
|
||||
|
||||
const validation = await validateSession(page, baseUrl, sessionToken)
|
||||
|
||||
expect(validation.valid).toBe(true)
|
||||
expect(validation.user_uuid).toBe(userUuid)
|
||||
|
||||
console.log(`✓ Session validated for user: ${validation.user_uuid}`)
|
||||
})
|
||||
|
||||
test('should retrieve user info', async ({ page }) => {
|
||||
test.skip(!sessionToken, 'Requires successful registration')
|
||||
|
||||
const userInfo = await getUserInfo(page, baseUrl, sessionToken)
|
||||
|
||||
expect(userInfo.user.user_uuid).toBe(userUuid)
|
||||
expect(userInfo.user.user_name).toBe('Admin User')
|
||||
expect(userInfo.credentials).toBeDefined()
|
||||
expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
console.log(`✓ User info retrieved: ${userInfo.user.user_name}`)
|
||||
console.log(`✓ Credentials count: ${userInfo.credentials.length}`)
|
||||
})
|
||||
|
||||
test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => {
|
||||
test.skip(!sessionToken, 'Requires successful registration')
|
||||
|
||||
// Navigate to page (required for WebAuthn origin)
|
||||
await page.goto('/auth/')
|
||||
|
||||
// The virtual authenticator in this context is new and doesn't have credentials.
|
||||
// Create a device link using the current session, then register a new credential.
|
||||
const deviceLink = await createDeviceLink(page, baseUrl, sessionToken)
|
||||
console.log(`✓ Created device link with token: ${deviceLink.token}`)
|
||||
|
||||
// Register a new credential using the device link
|
||||
const regResult = await registerPasskey(page, baseUrl, {
|
||||
resetToken: deviceLink.token,
|
||||
displayName: 'Admin User (test device)'
|
||||
})
|
||||
|
||||
console.log(`✓ Added test credential: ${regResult.credential_uuid}`)
|
||||
|
||||
// Now logout and authenticate with the fresh credential
|
||||
await logout(page, baseUrl, regResult.session_token)
|
||||
console.log('✓ Logged out')
|
||||
|
||||
// Authenticate with the virtual authenticator (now has a valid credential)
|
||||
const result = await authenticatePasskey(page, baseUrl)
|
||||
|
||||
expect(result.session_token).toBeDefined()
|
||||
expect(result.session_token).toHaveLength(16)
|
||||
expect(result.user_uuid).toBe(userUuid)
|
||||
|
||||
// Update session token for subsequent tests
|
||||
sessionToken = result.session_token
|
||||
|
||||
console.log(`✓ Authenticated as user: ${result.user_uuid}`)
|
||||
console.log(`✓ New session token: ${sessionToken.substring(0, 4)}...`)
|
||||
})
|
||||
|
||||
test('should validate new session after authentication', async ({ page }) => {
|
||||
test.skip(!sessionToken, 'Requires successful authentication')
|
||||
|
||||
const validation = await validateSession(page, baseUrl, sessionToken)
|
||||
|
||||
expect(validation.valid).toBe(true)
|
||||
expect(validation.user_uuid).toBe(userUuid)
|
||||
|
||||
console.log(`✓ New session validated`)
|
||||
})
|
||||
|
||||
test('should logout successfully', async ({ page }) => {
|
||||
test.skip(!sessionToken, 'Requires valid session')
|
||||
|
||||
await logout(page, baseUrl, sessionToken)
|
||||
|
||||
// Session should no longer be valid
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||||
headers: {
|
||||
'Cookie': `__Host-auth=${sessionToken}`,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
|
||||
expect(response.status()).toBe(401)
|
||||
console.log(`✓ Logout successful, session invalidated`)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Session Management', () => {
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:4401'
|
||||
|
||||
test('should reject invalid session token', async ({ page }) => {
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||||
headers: {
|
||||
'Cookie': '__Host-auth=invalid_token_123',
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
|
||||
// Server may return 400 (bad format) or 401 (unauthorized)
|
||||
expect([400, 401]).toContain(response.status())
|
||||
console.log(`✓ Invalid token correctly rejected`)
|
||||
})
|
||||
|
||||
test('should reject missing session token', async ({ page }) => {
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
|
||||
expect(response.status()).toBe(401)
|
||||
console.log(`✓ Missing token correctly rejected`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["tests/**/*.ts", "playwright.config.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PassKey Auth - Dev Mode</title>
|
||||
<style>
|
||||
: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>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🔐 PassKey Auth</h1>
|
||||
<p class="subtitle">Development server demonstration page.</p>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<div class="section">
|
||||
<h2>Management Site</h2>
|
||||
<button onclick="window.open('/auth/', '_blank')">👤 User Profile</button>
|
||||
<button onclick="window.open('/auth/admin/', '_blank')">⚙️ Admin Panel</button>
|
||||
</div>
|
||||
|
||||
<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/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>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Browser Mode (full page)</h2>
|
||||
<p>Block access to otherwise open site - intended for forward-auth mechanism (Caddy, Nxinx):</p>
|
||||
<button onclick="browserNav('/auth/api/forward')">🔐 Basic Auth</button>
|
||||
<button onclick="browserNav('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
|
||||
<button onclick="browserNav('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
|
||||
</div>
|
||||
|
||||
<pre id="output">Click a button to test...</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const output = document.getElementById('output');
|
||||
let pendingCall = null; // Stores the API call to retry after auth
|
||||
|
||||
// The auth iframe posts messages when authentication completes or is cancelled.
|
||||
// Message types: 'auth-success' (proceed), 'auth-back' (user cancelled)
|
||||
// Errors during auth stay in the dialog allowing retry, no message is sent.
|
||||
window.addEventListener('message', (event) => {
|
||||
const { type, message } = event.data || {};
|
||||
|
||||
if (type === 'auth-success') {
|
||||
log('✓ Authentication successful, retrying...');
|
||||
hideAuthIframe();
|
||||
// Retry the original API call that triggered authentication
|
||||
if (pendingCall) {
|
||||
const { url, method } = pendingCall;
|
||||
pendingCall = null;
|
||||
apiCall(url, method);
|
||||
}
|
||||
} else if (type === 'auth-back') {
|
||||
log(message || 'Authentication cancelled');
|
||||
hideAuthIframe();
|
||||
pendingCall = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Make an API call, handling 401/403 by showing the auth iframe.
|
||||
// The server returns JSON with auth.iframe URL when authentication is needed.
|
||||
async function apiCall(url, method = 'GET') {
|
||||
log(`${method} ${url}...`);
|
||||
|
||||
const response = await fetch(url, { method, credentials: 'include' });
|
||||
|
||||
// Server returns 401 (login/reauth) or 403 (missing permissions)
|
||||
// with a JSON body containing the iframe URL for authentication
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const data = await response.json();
|
||||
if (data.auth?.iframe) {
|
||||
const mode = data.auth.mode; // 'login' or 'reauth'
|
||||
log(`${mode === 'reauth' ? 'Re-authentication' : 'Authentication'} required...`);
|
||||
pendingCall = { url, method };
|
||||
showAuthIframe(data.auth.iframe);
|
||||
return;
|
||||
}
|
||||
log(`Error: ${response.status} - ${data.detail}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward endpoint returns 204 on success (Caddy then adds Remote-* headers)
|
||||
if (response.status === 204) {
|
||||
log('✓ Success (204 No Content)\nHeaders:\n' +
|
||||
[...response.headers].filter(([k]) => k.startsWith('remote-'))
|
||||
.map(([k, v]) => ` ${k}: ${v}`).join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
log(`Error: ${response.status} ${response.statusText}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
log('✓ Response:\n' + JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await fetch('/auth/api/logout', { method: 'POST', credentials: 'include' });
|
||||
log('Logged out');
|
||||
}
|
||||
|
||||
// Create fullscreen iframe for authentication.
|
||||
// The 'allow' attribute enables WebAuthn (passkey) API inside the iframe.
|
||||
function showAuthIframe(url) {
|
||||
hideAuthIframe();
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.id = 'auth-iframe';
|
||||
iframe.src = url;
|
||||
document.body.appendChild(iframe);
|
||||
log("Authentication dialog open...")
|
||||
}
|
||||
|
||||
function hideAuthIframe() {
|
||||
document.getElementById('auth-iframe')?.remove();
|
||||
}
|
||||
|
||||
function log(msg) {
|
||||
output.textContent = msg;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<StatusMessage />
|
||||
<main class="app-main">
|
||||
<HostProfileView v-if="authenticated && isHostMode" :initializing="loading" />
|
||||
<ProfileView v-else-if="authenticated" />
|
||||
<LoadingView v-else-if="loading" :message="loadingMessage" />
|
||||
<AuthRequiredMessage v-else-if="showBackMessage" @reload="reloadPage" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiJson, getAuthIframeUrl } from '@/utils/api'
|
||||
import StatusMessage from '@/components/StatusMessage.vue'
|
||||
import ProfileView from '@/components/ProfileView.vue'
|
||||
import HostProfileView from '@/components/HostProfileView.vue'
|
||||
import LoadingView from '@/components/LoadingView.vue'
|
||||
import AuthRequiredMessage from '@/components/AccessDenied.vue'
|
||||
|
||||
const store = useAuthStore()
|
||||
const loading = ref(true)
|
||||
const loadingMessage = ref('Loading...')
|
||||
const authenticated = ref(false)
|
||||
const showBackMessage = ref(false)
|
||||
|
||||
/**
|
||||
* Normalize a host string for comparison (lowercase, strip default ports).
|
||||
*/
|
||||
function normalizeHost(raw) {
|
||||
if (!raw) return null
|
||||
const trimmed = raw.trim().toLowerCase()
|
||||
if (!trimmed) return null
|
||||
// Remove default ports
|
||||
return trimmed.replace(/:80$/, '').replace(/:443$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Host mode is active when an auth_host is configured AND the current host differs from it.
|
||||
* In host mode, we show a limited profile view with logout and link to full profile.
|
||||
*/
|
||||
const isHostMode = computed(() => {
|
||||
const authHost = store.settings?.auth_host
|
||||
if (!authHost) return false
|
||||
const currentHost = normalizeHost(window.location.host)
|
||||
const configuredHost = normalizeHost(authHost)
|
||||
return currentHost !== configuredHost
|
||||
})
|
||||
let validationTimer = null
|
||||
let authIframe = null
|
||||
|
||||
async function loadUserInfo() {
|
||||
try {
|
||||
store.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
|
||||
authenticated.value = true
|
||||
loading.value = false
|
||||
startSessionValidation()
|
||||
return true
|
||||
} catch (e) {
|
||||
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 reloadPage() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
function handleAuthMessage(event) {
|
||||
const data = event.data
|
||||
if (!data?.type) return
|
||||
|
||||
switch (data.type) {
|
||||
case 'auth-success':
|
||||
// Authentication successful - reload user info
|
||||
hideAuthIframe()
|
||||
loading.value = true
|
||||
loadingMessage.value = 'Loading user profile...'
|
||||
loadUserInfo()
|
||||
break
|
||||
|
||||
case 'auth-error':
|
||||
// Authentication failed - keep iframe open so user can retry
|
||||
if (data.cancelled) {
|
||||
console.log('Authentication cancelled by user')
|
||||
} else {
|
||||
store.showMessage(data.message || 'Authentication failed', 'error', 5000)
|
||||
}
|
||||
break
|
||||
|
||||
case 'auth-cancelled':
|
||||
// Legacy support - treat as auth-error with cancelled flag
|
||||
console.log('Authentication cancelled')
|
||||
break
|
||||
|
||||
case 'auth-back':
|
||||
// User clicked Back - show message with reload option
|
||||
hideAuthIframe()
|
||||
loading.value = false
|
||||
showBackMessage.value = true
|
||||
store.showMessage('Authentication cancelled', 'info', 3000)
|
||||
break
|
||||
|
||||
case 'auth-close-request':
|
||||
// Legacy support - treat as back
|
||||
hideAuthIframe()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function validateSession() {
|
||||
try {
|
||||
await apiJson('/auth/api/validate', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
})
|
||||
// If successful, session was renewed automatically
|
||||
} catch (error) {
|
||||
if (error.status === 401) {
|
||||
// Session expired - need to re-authenticate
|
||||
console.log('Session expired, requiring re-authentication')
|
||||
authenticated.value = false
|
||||
loading.value = true
|
||||
stopSessionValidation()
|
||||
showAuthIframe()
|
||||
} else {
|
||||
console.error('Session validation error:', error)
|
||||
// Don't treat network errors as session expiry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startSessionValidation() {
|
||||
// Validate session every 2 minutes
|
||||
stopSessionValidation()
|
||||
validationTimer = setInterval(validateSession, 2 * 60 * 1000)
|
||||
}
|
||||
|
||||
function stopSessionValidation() {
|
||||
if (validationTimer) {
|
||||
clearInterval(validationTimer)
|
||||
validationTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Listen for postMessage from auth iframe
|
||||
window.addEventListener('message', handleAuthMessage)
|
||||
|
||||
// Load settings
|
||||
await store.loadSettings()
|
||||
|
||||
// Set appropriate page title based on mode
|
||||
const rpName = store.settings?.rp_name
|
||||
if (rpName) {
|
||||
// In host mode, show "account summary" style title
|
||||
// Settings are loaded but isHostMode depends on them, so check here
|
||||
const authHost = store.settings?.auth_host
|
||||
const inHostMode = authHost && normalizeHost(window.location.host) !== normalizeHost(authHost)
|
||||
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()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,561 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import StatusMessage from '@/components/StatusMessage.vue'
|
||||
import LoadingView from '@/components/LoadingView.vue'
|
||||
import AuthRequiredMessage from '@/components/AccessDenied.vue'
|
||||
import AdminOverview from '@/admin/AdminOverview.vue'
|
||||
import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
|
||||
import AdminUserDetail from '@/admin/AdminUserDetail.vue'
|
||||
import AdminDialogs from '@/admin/AdminDialogs.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings'
|
||||
import { apiJson } from '@/utils/api'
|
||||
|
||||
const info = ref(null)
|
||||
const loading = ref(true)
|
||||
const loadingMessage = ref('Loading...')
|
||||
const authenticated = ref(false)
|
||||
const showBackMessage = ref(false)
|
||||
const error = ref(null)
|
||||
const orgs = ref([])
|
||||
const permissions = ref([])
|
||||
const currentOrgId = ref(null) // UUID of selected org for detail view
|
||||
const currentUserId = ref(null) // UUID for user detail view
|
||||
const userDetail = ref(null) // cached user detail object
|
||||
const userLink = ref(null) // latest generated registration link
|
||||
const userLinkExpires = ref(null)
|
||||
const authStore = useAuthStore()
|
||||
const addingOrgForPermission = ref(null)
|
||||
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
|
||||
const editingPermId = ref(null)
|
||||
const renameIdValue = ref('')
|
||||
const editingPermDisplay = ref(null)
|
||||
const renameDisplayValue = ref('')
|
||||
const dialog = ref({ type: null, data: null, busy: false, error: '' })
|
||||
const safeIdRegex = /[^A-Za-z0-9:._~-]/g
|
||||
|
||||
function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') }
|
||||
|
||||
function handleGlobalClick(e) {
|
||||
if (!addingOrgForPermission.value) return
|
||||
const menu = e.target.closest('.org-add-menu')
|
||||
const trigger = e.target.closest('.add-org-btn')
|
||||
if (!menu && !trigger) {
|
||||
addingOrgForPermission.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('click', handleGlobalClick)
|
||||
window.addEventListener('hashchange', parseHash)
|
||||
const settings = await getSettings()
|
||||
if (settings?.rp_name) document.title = settings.rp_name + ' Admin'
|
||||
await load()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleGlobalClick)
|
||||
window.removeEventListener('hashchange', parseHash)
|
||||
})
|
||||
|
||||
// Build a summary: for each permission id -> { orgs: Set(org_display_name), userCount }
|
||||
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 || [])
|
||||
|
||||
// Org-level permissions (direct) - only count if org can grant them
|
||||
for (const pid of 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)
|
||||
summary[pid].orgSet.add(o.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
// Role-based permissions (inheritance) - only count if org can grant them
|
||||
for (const r of o.roles) {
|
||||
for (const pid of r.permissions) {
|
||||
// Only count if the org can grant this permission
|
||||
if (!orgPerms.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
|
||||
}
|
||||
}
|
||||
}
|
||||
const display = {}
|
||||
for (const [pid, v] of Object.entries(summary)) {
|
||||
display[pid] = { orgs: v.orgs.sort((a,b)=>a.display_name.localeCompare(b.display_name)), userCount: v.userCount }
|
||||
}
|
||||
return display
|
||||
})
|
||||
|
||||
function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p, id: p.id, display_name: p.display_name }) }
|
||||
|
||||
async function refreshPermissionsContext() {
|
||||
// Reload both lists so All Permissions table shows new associations promptly.
|
||||
await Promise.all([loadPermissions(), loadOrgs()])
|
||||
}
|
||||
|
||||
async function attachPermissionToOrg(pid, orgUuid) {
|
||||
if (!orgUuid) return
|
||||
try {
|
||||
const params = new URLSearchParams({ permission_id: pid })
|
||||
await apiJson(`/auth/api/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'POST' })
|
||||
await loadOrgs()
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to add permission to org')
|
||||
}
|
||||
}
|
||||
|
||||
async function detachPermissionFromOrg(pid, orgUuid) {
|
||||
openDialog('confirm', { message: 'Remove permission from this org?', action: async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ permission_id: pid })
|
||||
await apiJson(`/auth/api/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'DELETE' })
|
||||
await loadOrgs()
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to remove permission from org')
|
||||
}
|
||||
} })
|
||||
}
|
||||
|
||||
function parseHash() {
|
||||
const h = window.location.hash || ''
|
||||
currentOrgId.value = null
|
||||
currentUserId.value = null
|
||||
if (h.startsWith('#org/')) {
|
||||
currentOrgId.value = h.slice(5)
|
||||
} else if (h.startsWith('#user/')) {
|
||||
currentUserId.value = h.slice(6)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrgs() {
|
||||
const data = await apiJson('/auth/api/admin/orgs')
|
||||
orgs.value = data.map(o => {
|
||||
const roles = o.roles.map(r => ({ ...r, org_uuid: 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 loadPermissions() {
|
||||
permissions.value = await apiJson('/auth/api/admin/permissions')
|
||||
}
|
||||
|
||||
async function loadUserInfo() {
|
||||
info.value = await apiJson('/auth/api/user-info', { method: 'POST' })
|
||||
authenticated.value = true
|
||||
}
|
||||
|
||||
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()])
|
||||
// If we get here, user has admin access - now fetch user info for display
|
||||
await loadUserInfo()
|
||||
|
||||
if (!info.value.is_global_admin && info.value.is_org_admin && orgs.value.length === 1) {
|
||||
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) {
|
||||
if (e.name === 'AuthCancelledError') {
|
||||
showBackMessage.value = true
|
||||
} else {
|
||||
error.value = e.message
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Org actions
|
||||
function createOrg() { openDialog('org-create', {}) }
|
||||
|
||||
function updateOrg(org) { openDialog('org-update', { org, name: org.display_name }) }
|
||||
|
||||
function editUserName(user) { openDialog('user-update-name', { user, name: user.display_name }) }
|
||||
|
||||
function deleteOrg(org) {
|
||||
if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return }
|
||||
openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => {
|
||||
await apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'DELETE' })
|
||||
await Promise.all([loadOrgs(), loadPermissions()])
|
||||
} })
|
||||
}
|
||||
|
||||
function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
|
||||
|
||||
async function moveUserToRole(org, user, targetRoleDisplayName) {
|
||||
if (user.role === targetRoleDisplayName) return
|
||||
try {
|
||||
await apiJson(`/auth/api/admin/orgs/${org.uuid}/users/${user.uuid}/role`, {
|
||||
method: 'PUT',
|
||||
body: { role: targetRoleDisplayName }
|
||||
})
|
||||
await loadOrgs()
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to update user role')
|
||||
}
|
||||
}
|
||||
|
||||
function onUserDragStart(e, user, org_uuid) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: user.uuid, org_uuid }))
|
||||
}
|
||||
|
||||
function onRoleDragOver(e) {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
function onRoleDrop(e, org, role) {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('text/plain'))
|
||||
if (data.org_uuid !== org.uuid) return // only within same org
|
||||
const user = org.roles.flatMap(r => r.users).find(u => u.uuid === data.user_uuid)
|
||||
if (user) moveUserToRole(org, user, role.display_name)
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
// (legacy function retained but unused in UI)
|
||||
async function addOrgPermission() { /* obsolete */ }
|
||||
|
||||
async function removeOrgPermission() { /* obsolete */ }
|
||||
|
||||
// Role actions
|
||||
function createRole(org) { openDialog('role-create', { org }) }
|
||||
|
||||
function updateRole(role) { openDialog('role-update', { role, name: role.display_name }) }
|
||||
|
||||
function deleteRole(role) {
|
||||
openDialog('confirm', { message: `Delete role ${role.display_name}?`, action: async () => {
|
||||
await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'DELETE' })
|
||||
await loadOrgs()
|
||||
} })
|
||||
}
|
||||
|
||||
async function toggleRolePermission(role, pid, checked) {
|
||||
// Calculate new permissions array
|
||||
const newPermissions = checked
|
||||
? [...role.permissions, pid]
|
||||
: role.permissions.filter(p => p !== pid)
|
||||
|
||||
// Optimistic update
|
||||
const prevPermissions = [...role.permissions]
|
||||
role.permissions = newPermissions
|
||||
|
||||
try {
|
||||
await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, {
|
||||
method: 'PUT',
|
||||
body: { display_name: role.display_name, permissions: newPermissions }
|
||||
})
|
||||
await loadOrgs()
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to update role permission')
|
||||
role.permissions = prevPermissions // revert
|
||||
}
|
||||
}
|
||||
|
||||
// Permission actions
|
||||
function updatePermission(p) { openDialog('perm-display', { permission: p }) }
|
||||
|
||||
function deletePermission(p) {
|
||||
openDialog('confirm', { message: `Delete permission ${p.id}?`, action: async () => {
|
||||
const params = new URLSearchParams({ permission_id: p.id })
|
||||
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' })
|
||||
await loadPermissions()
|
||||
} })
|
||||
}
|
||||
|
||||
function reloadPage() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
|
||||
|
||||
function openOrg(o) {
|
||||
window.location.hash = `#org/${o.uuid}`
|
||||
}
|
||||
|
||||
function goOverview() {
|
||||
window.location.hash = '#overview'
|
||||
}
|
||||
|
||||
function openUser(u) {
|
||||
window.location.hash = `#user/${u.uuid}`
|
||||
}
|
||||
|
||||
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_uuid: o.uuid, role_display_name: r.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() }
|
||||
]
|
||||
// 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_uuid) || null
|
||||
}
|
||||
const orgToShow = selectedOrg.value || orgForUser
|
||||
if (orgToShow) {
|
||||
entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` })
|
||||
}
|
||||
if (selectedUser.value) {
|
||||
entries.push({ label: selectedUser.value.display_name || 'User', href: `#user/${selectedUser.value.uuid}` })
|
||||
}
|
||||
return entries
|
||||
})
|
||||
|
||||
watch(selectedUser, async (u) => {
|
||||
if (!u) { userDetail.value = null; return }
|
||||
try {
|
||||
userDetail.value = await apiJson(`/auth/api/admin/orgs/${u.org_uuid}/users/${u.uuid}`)
|
||||
} catch (e) {
|
||||
userDetail.value = { error: e.message }
|
||||
}
|
||||
})
|
||||
|
||||
const showRegModal = ref(false)
|
||||
function generateUserRegistrationLink(u) {
|
||||
showRegModal.value = true
|
||||
}
|
||||
|
||||
function onLinkCopied() {
|
||||
authStore.showMessage('Link copied to clipboard!')
|
||||
}
|
||||
|
||||
function copy(text) {
|
||||
if (!text) return
|
||||
navigator.clipboard.writeText(text)
|
||||
.catch(()=>{})
|
||||
}
|
||||
|
||||
function permissionDisplayName(id) {
|
||||
return permissions.value.find(p => p.id === id)?.display_name || id
|
||||
}
|
||||
|
||||
async function toggleOrgPermission(org, permId, checked) {
|
||||
// Build next permission list
|
||||
const has = org.permissions.includes(permId)
|
||||
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
|
||||
try {
|
||||
const params = new URLSearchParams({ permission_id: permId })
|
||||
await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
|
||||
await loadOrgs()
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to update organization permission')
|
||||
org.permissions = prev // revert
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(type, data) { dialog.value = { type, data, busy: false, error: '' } }
|
||||
function closeDialog() { dialog.value = { type: null, data: null, busy: false, error: '' } }
|
||||
|
||||
async function refreshUserDetail() {
|
||||
await loadOrgs()
|
||||
if (selectedUser.value) {
|
||||
try {
|
||||
userDetail.value = await apiJson(`/auth/api/admin/orgs/${selectedUser.value.org_uuid}/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
|
||||
dialog.value.busy = true; dialog.value.error = ''
|
||||
try {
|
||||
const t = dialog.value.type
|
||||
if (t === 'org-create') {
|
||||
const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||
await apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } })
|
||||
await Promise.all([loadOrgs(), loadPermissions()])
|
||||
} else if (t === 'org-update') {
|
||||
const { org } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||
await apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PUT', body: { display_name: name, permissions: org.permissions } })
|
||||
await loadOrgs()
|
||||
} else if (t === 'role-create') {
|
||||
const { org } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||
await apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } })
|
||||
await loadOrgs()
|
||||
} else if (t === 'role-update') {
|
||||
const { role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||
await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', body: { display_name: name, permissions: role.permissions } })
|
||||
await loadOrgs()
|
||||
} else if (t === 'user-create') {
|
||||
const { org, role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||
await apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } })
|
||||
await loadOrgs()
|
||||
} else if (t === 'user-update-name') {
|
||||
const { user } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||
await apiJson(`/auth/api/admin/orgs/${user.org_uuid}/users/${user.uuid}/display-name`, { method: 'PUT', body: { display_name: name } })
|
||||
await onUserNameSaved()
|
||||
} else if (t === 'perm-display') {
|
||||
const { permission } = dialog.value.data
|
||||
const newId = dialog.value.data.id?.trim()
|
||||
const newDisplay = dialog.value.data.display_name?.trim()
|
||||
if (!newDisplay) throw new Error('Display name required')
|
||||
if (!newId) throw new Error('ID required')
|
||||
|
||||
if (newId !== permission.id) {
|
||||
// ID changed, use rename endpoint
|
||||
await apiJson('/auth/api/admin/permission/rename', { method: 'POST', body: { old_id: permission.id, new_id: newId, display_name: newDisplay } })
|
||||
} else if (newDisplay !== permission.display_name) {
|
||||
// Only display name changed
|
||||
const params = new URLSearchParams({ permission_id: permission.id, display_name: newDisplay })
|
||||
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PUT' })
|
||||
}
|
||||
await loadPermissions()
|
||||
} else if (t === 'perm-create') {
|
||||
const id = dialog.value.data.id?.trim(); if (!id) throw new Error('ID required')
|
||||
const display_name = dialog.value.data.display_name?.trim(); if (!display_name) throw new Error('Display name required')
|
||||
await apiJson('/auth/api/admin/permissions', { method: 'POST', body: { id, display_name } })
|
||||
await loadPermissions(); dialog.value.data.display_name = ''; dialog.value.data.id = ''
|
||||
} else if (t === 'confirm') {
|
||||
const action = dialog.value.data.action; if (action) await action()
|
||||
}
|
||||
closeDialog()
|
||||
} catch (e) {
|
||||
dialog.value.error = e.message || 'Error'
|
||||
} finally { dialog.value.busy = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell admin-shell">
|
||||
<StatusMessage />
|
||||
<main class="app-main">
|
||||
<LoadingView v-if="loading" :message="loadingMessage" />
|
||||
<AuthRequiredMessage
|
||||
v-else-if="showBackMessage"
|
||||
@reload="reloadPage"
|
||||
/>
|
||||
<section v-else-if="authenticated && (info?.is_global_admin || info?.is_org_admin)" class="view-root view-root--wide view-admin">
|
||||
<header class="view-header">
|
||||
<h1>{{ pageHeading }}</h1>
|
||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
||||
</header>
|
||||
|
||||
<section class="section-block admin-section">
|
||||
<div class="section-body admin-section-body">
|
||||
<div v-if="error" class="surface surface--tight error">{{ error }}</div>
|
||||
<div v-else class="admin-panels">
|
||||
<AdminOverview
|
||||
v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)"
|
||||
:info="info"
|
||||
:orgs="orgs"
|
||||
:permissions="permissions"
|
||||
:permission-summary="permissionSummary"
|
||||
@create-org="createOrg"
|
||||
@open-org="openOrg"
|
||||
@update-org="updateOrg"
|
||||
@delete-org="deleteOrg"
|
||||
@toggle-org-permission="toggleOrgPermission"
|
||||
@open-dialog="openDialog"
|
||||
@delete-permission="deletePermission"
|
||||
@rename-permission-display="renamePermissionDisplay"
|
||||
/>
|
||||
|
||||
<AdminUserDetail
|
||||
v-else-if="selectedUser"
|
||||
:selected-user="selectedUser"
|
||||
:user-detail="userDetail"
|
||||
:selected-org="selectedOrg"
|
||||
:loading="loading"
|
||||
:show-reg-modal="showRegModal"
|
||||
@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"
|
||||
/>
|
||||
<AdminOrgDetail
|
||||
v-else-if="selectedOrg"
|
||||
:selected-org="selectedOrg"
|
||||
:permissions="permissions"
|
||||
@update-org="updateOrg"
|
||||
@create-role="createRole"
|
||||
@update-role="updateRole"
|
||||
@delete-role="deleteRole"
|
||||
@create-user-in-role="createUserInRole"
|
||||
@open-user="openUser"
|
||||
@toggle-role-permission="toggleRolePermission"
|
||||
@on-role-drag-over="onRoleDragOver"
|
||||
@on-role-drop="onRoleDrop"
|
||||
@on-user-drag-start="onUserDragStart"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
<AdminDialogs
|
||||
:dialog="dialog"
|
||||
:permission-id-pattern="PERMISSION_ID_PATTERN"
|
||||
@submit-dialog="submitDialog"
|
||||
@close-dialog="closeDialog"
|
||||
/>
|
||||
</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>
|
||||
@@ -7,6 +7,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="admin-app"></div>
|
||||
<script type="module" src="/src/admin/main.js"></script>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
import '../assets/style.css'
|
||||
import '@/assets/style.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
@@ -3,10 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Authentication</title>
|
||||
<title>Auth Profile</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
<script type="module" src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
import './assets/style.css'
|
||||
import '@/assets/style.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<RestrictedAuth
|
||||
:mode="authMode"
|
||||
@authenticated="handleAuthenticated"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
||||
|
||||
// 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'
|
||||
})
|
||||
|
||||
function postToParent(message) {
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage(message, '*')
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthenticated(result) {
|
||||
postToParent({
|
||||
type: 'auth-success',
|
||||
authenticated: true,
|
||||
sessionToken: result.session_token
|
||||
})
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
postToParent({
|
||||
type: 'auth-back'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
postToParent({
|
||||
type: 'auth-ready'
|
||||
})
|
||||
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleBack()
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,4 @@
|
||||
<html style="background: transparent"><meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/auth/restricted/main.js"></script>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import RestrictedApi from './RestrictedApi.vue'
|
||||
import '@/assets/style.css'
|
||||
|
||||
createApp(RestrictedApi).mount('#app')
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<RestrictedAuth
|
||||
:mode="authMode"
|
||||
@authenticated="handleAuthenticated"
|
||||
@back="goBack"
|
||||
@home="returnHome"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
||||
import { uiBasePath } from '@/utils/settings'
|
||||
import { goBack } from '@/utils/helpers'
|
||||
|
||||
const basePath = computed(() => uiBasePath())
|
||||
|
||||
// Detect mode from data attribute on html tag only
|
||||
// (RestrictedApi uses URL query, RestrictedForward uses data injected by server)
|
||||
const authMode = computed(() => {
|
||||
const htmlElement = document.documentElement
|
||||
const dataMode = htmlElement.getAttribute('data-mode')
|
||||
if (dataMode === 'reauth') return 'reauth'
|
||||
if (dataMode === 'forbidden') return 'forbidden'
|
||||
return 'login'
|
||||
})
|
||||
|
||||
function handleAuthenticated() {
|
||||
// Reload page to re-trigger forward auth validation
|
||||
location.reload()
|
||||
}
|
||||
|
||||
function returnHome() {
|
||||
const target = basePath.value || '/auth/'
|
||||
if (window.location.pathname !== target) history.replaceState(null, '', target)
|
||||
window.location.href = target
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Handle Escape key to trigger back navigation
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') goBack()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Access Restricted</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/int/forward/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './RestrictedForward.vue'
|
||||
import '@/assets/style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<div v-if="status.show" class="global-status" style="display: block;">
|
||||
<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;">
|
||||
<h1>🔑 Registration</h1>
|
||||
<p class="view-lede">
|
||||
{{ subtitleMessage }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="section-block" v-if="initializing">
|
||||
<div class="section-body center">
|
||||
<p>Loading reset details…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-block" v-else-if="!canRegister">
|
||||
<div class="section-body center">
|
||||
<p>{{ errorMessage }}</p>
|
||||
<div class="button-row center" style="justify-content: center;">
|
||||
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-block" v-else>
|
||||
<div class="section-body">
|
||||
<label class="name-edit">
|
||||
<span>👤 Name</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="displayName"
|
||||
:disabled="loading"
|
||||
maxlength="64"
|
||||
@keyup.enter="registerPasskey"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
class="btn-primary"
|
||||
:disabled="loading"
|
||||
@click="registerPasskey"
|
||||
>
|
||||
{{ loading ? 'Registering…' : 'Register Passkey' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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'
|
||||
|
||||
const status = reactive({
|
||||
show: false,
|
||||
message: '',
|
||||
type: 'info'
|
||||
})
|
||||
|
||||
const initializing = ref(true)
|
||||
const loading = ref(false)
|
||||
const token = ref('')
|
||||
const settings = ref(null)
|
||||
const userInfo = ref(null)
|
||||
const displayName = ref('')
|
||||
const errorMessage = ref('')
|
||||
let statusTimer = null
|
||||
|
||||
const sessionDescriptor = computed(() => userInfo.value?.session_type || 'your enrollment')
|
||||
const subtitleMessage = computed(() => {
|
||||
if (initializing.value) return 'Preparing your secure enrollment…'
|
||||
if (!canRegister.value) return 'This reset 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.`
|
||||
})
|
||||
|
||||
const basePath = computed(() => uiBasePath())
|
||||
|
||||
const canRegister = computed(() => !!(token.value && userInfo.value))
|
||||
|
||||
function showMessage(message, type = 'info', duration = 3000) {
|
||||
status.show = true
|
||||
status.message = message
|
||||
status.type = type
|
||||
if (statusTimer) clearTimeout(statusTimer)
|
||||
if (duration > 0) {
|
||||
statusTimer = setTimeout(() => {
|
||||
status.show = false
|
||||
}, duration)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSettings() {
|
||||
try {
|
||||
const data = await getSettings()
|
||||
settings.value = data
|
||||
if (data?.rp_name) document.title = `${data.rp_name} · Passkey Setup`
|
||||
} catch (error) {
|
||||
console.warn('Unable to load settings', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserInfo() {
|
||||
if (!token.value) return
|
||||
try {
|
||||
userInfo.value = await apiJson(`/auth/api/user-info?reset=${encodeURIComponent(token.value)}`, {
|
||||
method: 'POST'
|
||||
})
|
||||
displayName.value = userInfo.value?.user?.user_name || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to load user info', error)
|
||||
const message = error instanceof ApiError
|
||||
? (error.data?.detail || 'Reset link is invalid or expired.')
|
||||
: getUserFriendlyErrorMessage(error)
|
||||
errorMessage.value = message
|
||||
showMessage(message, 'error', 0)
|
||||
}
|
||||
}
|
||||
|
||||
async function registerPasskey() {
|
||||
if (!canRegister.value || loading.value) return
|
||||
loading.value = true
|
||||
showMessage('Starting passkey registration…', 'info')
|
||||
|
||||
let result
|
||||
try {
|
||||
const nameValue = displayName.value.trim() || null
|
||||
result = await passkey.register(token.value, nameValue)
|
||||
} catch (error) {
|
||||
loading.value = false
|
||||
const message = error?.message || 'Passkey registration cancelled'
|
||||
const cancelled = message === 'Passkey registration cancelled'
|
||||
showMessage(cancelled ? message : `Registration failed: ${message}`, cancelled ? 'info' : 'error', 4000)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await setSessionCookie(result)
|
||||
} catch (error) {
|
||||
loading.value = false
|
||||
const message = error?.message || 'Failed to establish session'
|
||||
showMessage(message, 'error', 4000)
|
||||
return
|
||||
}
|
||||
|
||||
showMessage('Passkey registered successfully!', 'success', 800)
|
||||
setTimeout(() => { loading.value = false; goHome() }, 800)
|
||||
}
|
||||
|
||||
async function setSessionCookie(result) {
|
||||
if (!result?.session_token) {
|
||||
throw new Error('Registration response missing session_token')
|
||||
}
|
||||
return await apiJson('/auth/api/set-session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${result.session_token}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
const target = uiBasePath.value || '/auth/'
|
||||
if (window.location.pathname !== target) {
|
||||
history.replaceState(null, '', target)
|
||||
}
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
function extractTokenFromPath() {
|
||||
const segments = window.location.pathname.split('/').filter(Boolean)
|
||||
if (!segments.length) return ''
|
||||
const candidate = segments[segments.length - 1]
|
||||
const prefix = segments.slice(0, -1)
|
||||
if (prefix.length > 1) return ''
|
||||
if (prefix.length === 1 && prefix[0] !== 'auth') return ''
|
||||
if (!candidate.includes('.')) return ''
|
||||
return candidate
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
token.value = extractTokenFromPath()
|
||||
await fetchSettings()
|
||||
if (!token.value) {
|
||||
const message = 'Reset link is missing or malformed.'
|
||||
errorMessage.value = message
|
||||
showMessage(message, 'error', 0)
|
||||
initializing.value = false
|
||||
return
|
||||
}
|
||||
await fetchUserInfo()
|
||||
initializing.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button-row.center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.section-body {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.name-edit span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Complete Passkey Setup</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/int/reset/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import ResetApp from './ResetApp.vue'
|
||||
import '@/assets/style.css'
|
||||
|
||||
createApp(ResetApp).mount('#app')
|
||||
@@ -12,6 +12,7 @@
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"pinia": "^3.0.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"sirv": "^3.0.2",
|
||||
"vue": "^3.5.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<StatusMessage />
|
||||
<LoginView v-if="store.currentView === 'login'" />
|
||||
<ProfileView v-if="store.currentView === 'profile'" />
|
||||
<DeviceLinkView v-if="store.currentView === 'device-link'" />
|
||||
<ResetView v-if="store.currentView === 'reset'" />
|
||||
<PermissionDeniedView v-if="store.currentView === 'permission-denied'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import StatusMessage from '@/components/StatusMessage.vue'
|
||||
import LoginView from '@/components/LoginView.vue'
|
||||
import ProfileView from '@/components/ProfileView.vue'
|
||||
import DeviceLinkView from '@/components/DeviceLinkView.vue'
|
||||
import ResetView from '@/components/ResetView.vue'
|
||||
import PermissionDeniedView from '@/components/PermissionDeniedView.vue'
|
||||
|
||||
const store = useAuthStore()
|
||||
|
||||
onMounted(async () => {
|
||||
// Detect restricted mode:
|
||||
// We only allow full functionality on the exact /auth/ (or /auth) path.
|
||||
// Any other path (including /, /foo, /auth/admin, etc.) is treated as restricted
|
||||
// so the app will only show login or permission denied views.
|
||||
const path = location.pathname
|
||||
if (!(path === '/auth/' || path === '/auth')) {
|
||||
store.setRestrictedMode(true)
|
||||
}
|
||||
// Load branding / settings first (non-blocking for auth flow)
|
||||
await store.loadSettings()
|
||||
// Was an error message passed in the URL hash?
|
||||
const message = location.hash.substring(1)
|
||||
if (message) {
|
||||
store.showMessage(decodeURIComponent(message), 'error')
|
||||
history.replaceState(null, '', location.pathname)
|
||||
}
|
||||
// Capture reset token from query parameter and then remove it
|
||||
const params = new URLSearchParams(location.search)
|
||||
const reset = params.get('reset')
|
||||
if (reset) {
|
||||
store.resetToken = reset
|
||||
// Remove query param to avoid lingering in history / clipboard
|
||||
const targetPath = '/auth/'
|
||||
const currentPath = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/'
|
||||
history.replaceState(null, '', currentPath.startsWith('/auth') ? '/auth/' : targetPath)
|
||||
}
|
||||
try {
|
||||
await store.loadUserInfo()
|
||||
} catch (error) {
|
||||
console.log('Failed to load user info:', error)
|
||||
store.currentView = 'login'
|
||||
}
|
||||
store.selectView()
|
||||
})
|
||||
</script>
|
||||
@@ -1,887 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import StatusMessage from '@/components/StatusMessage.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const info = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const orgs = ref([])
|
||||
const permissions = ref([])
|
||||
const currentOrgId = ref(null) // UUID of selected org for detail view
|
||||
const currentUserId = ref(null) // UUID for user detail view
|
||||
const userDetail = ref(null) // cached user detail object
|
||||
const userLink = ref(null) // latest generated registration link
|
||||
const userLinkExpires = ref(null)
|
||||
const authStore = useAuthStore()
|
||||
const addingOrgForPermission = ref(null)
|
||||
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
|
||||
const showCreatePermission = ref(false)
|
||||
const newPermId = ref('')
|
||||
const newPermName = ref('')
|
||||
const editingPermId = ref(null)
|
||||
const renameIdValue = ref('')
|
||||
const dialog = ref({ type: null, data: null, busy: false, error: '' })
|
||||
const safeIdRegex = /[^A-Za-z0-9:._~-]/g
|
||||
|
||||
function sanitizeNewId() { if (newPermId.value) newPermId.value = newPermId.value.replace(safeIdRegex, '') }
|
||||
function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') }
|
||||
|
||||
function handleGlobalClick(e) {
|
||||
if (!addingOrgForPermission.value) return
|
||||
const menu = e.target.closest('.org-add-menu')
|
||||
const trigger = e.target.closest('.add-org-btn')
|
||||
if (!menu && !trigger) {
|
||||
addingOrgForPermission.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleGlobalClick)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleGlobalClick)
|
||||
})
|
||||
|
||||
// Build a summary: for each permission id -> { orgs: Set(org_display_name), userCount }
|
||||
const permissionSummary = computed(() => {
|
||||
const summary = {}
|
||||
for (const o of orgs.value) {
|
||||
const orgBase = { uuid: o.uuid, display_name: o.display_name }
|
||||
// Org-level permissions (direct)
|
||||
for (const pid of 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)
|
||||
summary[pid].orgSet.add(o.uuid)
|
||||
}
|
||||
}
|
||||
// Role-based permissions (inheritance)
|
||||
for (const r of o.roles) {
|
||||
for (const pid of r.permissions) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
const display = {}
|
||||
for (const [pid, v] of Object.entries(summary)) {
|
||||
display[pid] = { orgs: v.orgs.sort((a,b)=>a.display_name.localeCompare(b.display_name)), userCount: v.userCount }
|
||||
}
|
||||
return display
|
||||
})
|
||||
|
||||
function availableOrgsForPermission(pid) {
|
||||
return orgs.value.filter(o => !o.permissions.includes(pid))
|
||||
}
|
||||
|
||||
function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p }) }
|
||||
|
||||
function startRenamePermissionId(p) { editingPermId.value = p.id; renameIdValue.value = p.id }
|
||||
function cancelRenameId() { editingPermId.value = null; renameIdValue.value = '' }
|
||||
async function submitRenamePermissionId(p) {
|
||||
const newId = renameIdValue.value.trim()
|
||||
if (!newId || newId === p.id) { cancelRenameId(); return }
|
||||
try {
|
||||
const body = { old_id: p.id, new_id: newId, display_name: p.display_name }
|
||||
const res = await fetch('/auth/admin/permission/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
let data; try { data = await res.json() } catch(_) { data = {} }
|
||||
if (!res.ok || data.detail) throw new Error(data.detail || data.error || `Failed (${res.status})`)
|
||||
await refreshPermissionsContext(); cancelRenameId()
|
||||
} catch (e) { authStore.showMessage(e?.message || 'Rename failed') }
|
||||
}
|
||||
|
||||
async function refreshPermissionsContext() {
|
||||
// Reload both lists so All Permissions table shows new associations promptly.
|
||||
await Promise.all([loadPermissions(), loadOrgs()])
|
||||
}
|
||||
|
||||
async function attachPermissionToOrg(pid, orgUuid) {
|
||||
if (!orgUuid) return
|
||||
try {
|
||||
const params = new URLSearchParams({ permission_id: pid })
|
||||
const res = await fetch(`/auth/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
await loadOrgs()
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to add permission to org')
|
||||
}
|
||||
}
|
||||
|
||||
async function detachPermissionFromOrg(pid, orgUuid) {
|
||||
openDialog('confirm', { message: 'Remove permission from this org?', action: async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ permission_id: pid })
|
||||
const res = await fetch(`/auth/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
await loadOrgs()
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to remove permission from org')
|
||||
}
|
||||
} })
|
||||
}
|
||||
|
||||
function parseHash() {
|
||||
const h = window.location.hash || ''
|
||||
currentOrgId.value = null
|
||||
currentUserId.value = null
|
||||
if (h.startsWith('#org/')) {
|
||||
currentOrgId.value = h.slice(5)
|
||||
} else if (h.startsWith('#user/')) {
|
||||
currentUserId.value = h.slice(6)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrgs() {
|
||||
const res = await fetch('/auth/admin/orgs')
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
// Restructure to attach users to roles instead of flat user list at org level
|
||||
orgs.value = data.map(o => {
|
||||
const roles = o.roles.map(r => ({ ...r, org_uuid: 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 loadPermissions() {
|
||||
const res = await fetch('/auth/admin/permissions')
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
permissions.value = data
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await fetch('/auth/api/user-info', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
info.value = data
|
||||
if (data.authenticated && (data.is_global_admin || data.is_org_admin)) {
|
||||
await Promise.all([loadOrgs(), loadPermissions()])
|
||||
}
|
||||
// After loading orgs decide view if not global admin
|
||||
if (!data.is_global_admin && data.is_org_admin && orgs.value.length === 1) {
|
||||
if (!window.location.hash || window.location.hash === '#overview') {
|
||||
currentOrgId.value = orgs.value[0].uuid
|
||||
window.location.hash = `#org/${currentOrgId.value}`
|
||||
} else {
|
||||
parseHash()
|
||||
}
|
||||
} else parseHash()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Org actions
|
||||
function createOrg() { openDialog('org-create', {}) }
|
||||
|
||||
function updateOrg(org) { openDialog('org-update', { org }) }
|
||||
|
||||
function deleteOrg(org) {
|
||||
if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return }
|
||||
openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => {
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' })
|
||||
const data = await res.json(); if (data.detail) throw new Error(data.detail)
|
||||
await loadOrgs()
|
||||
} })
|
||||
}
|
||||
|
||||
function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
|
||||
|
||||
async function moveUserToRole(org, user, targetRoleDisplayName) {
|
||||
if (user.role === targetRoleDisplayName) return
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/users/${user.uuid}/role`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ role: targetRoleDisplayName })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.detail) { authStore.showMessage(data.detail); return }
|
||||
await loadOrgs()
|
||||
}
|
||||
|
||||
function onUserDragStart(e, user, org_uuid) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: user.uuid, org_uuid }))
|
||||
}
|
||||
|
||||
function onRoleDragOver(e) {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
function onRoleDrop(e, org, role) {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('text/plain'))
|
||||
if (data.org_uuid !== org.uuid) return // only within same org
|
||||
const user = org.roles.flatMap(r => r.users).find(u => u.uuid === data.user_uuid)
|
||||
if (user) moveUserToRole(org, user, role.display_name)
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
// (legacy function retained but unused in UI)
|
||||
async function addOrgPermission() { /* obsolete */ }
|
||||
|
||||
async function removeOrgPermission() { /* obsolete */ }
|
||||
|
||||
// Role actions
|
||||
function createRole(org) { openDialog('role-create', { org }) }
|
||||
|
||||
function updateRole(role) { openDialog('role-update', { role }) }
|
||||
|
||||
function deleteRole(role) {
|
||||
openDialog('confirm', { message: `Delete role ${role.display_name}?`, action: async () => {
|
||||
const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'DELETE' })
|
||||
const data = await res.json(); if (data.detail) throw new Error(data.detail)
|
||||
await loadOrgs()
|
||||
} })
|
||||
}
|
||||
|
||||
// Permission actions
|
||||
async function submitCreatePermission() {
|
||||
const id = newPermId.value.trim()
|
||||
const name = newPermName.value.trim()
|
||||
if (!id || !name) return
|
||||
const res = await fetch('/auth/admin/permissions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, display_name: name }) })
|
||||
const data = await res.json(); if (data.detail) { authStore.showMessage(data.detail); return }
|
||||
await loadPermissions(); newPermId.value=''; newPermName.value=''; showCreatePermission.value=false
|
||||
}
|
||||
function cancelCreatePermission() { newPermId.value=''; newPermName.value=''; showCreatePermission.value=false }
|
||||
|
||||
function updatePermission(p) { openDialog('perm-display', { permission: p }) }
|
||||
|
||||
function deletePermission(p) {
|
||||
openDialog('confirm', { message: `Delete permission ${p.id}?`, action: async () => {
|
||||
const params = new URLSearchParams({ permission_id: p.id })
|
||||
const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'DELETE' })
|
||||
const data = await res.json(); if (data.detail) throw new Error(data.detail)
|
||||
await loadPermissions()
|
||||
} })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('hashchange', parseHash)
|
||||
await authStore.loadSettings()
|
||||
if (authStore.settings?.rp_name) {
|
||||
document.title = authStore.settings.rp_name + ' Admin'
|
||||
}
|
||||
load()
|
||||
})
|
||||
|
||||
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
|
||||
|
||||
function openOrg(o) {
|
||||
window.location.hash = `#org/${o.uuid}`
|
||||
}
|
||||
|
||||
function goOverview() {
|
||||
window.location.hash = '#overview'
|
||||
}
|
||||
|
||||
function openUser(u) {
|
||||
window.location.hash = `#user/${u.uuid}`
|
||||
}
|
||||
|
||||
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_uuid: o.uuid, role_display_name: r.display_name }
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const pageHeading = computed(() => {
|
||||
if (selectedUser.value) return 'Organization Admin'
|
||||
if (selectedOrg.value) return 'Organization Admin'
|
||||
return (authStore.settings?.rp_name || 'Passkey') + ' Admin'
|
||||
})
|
||||
|
||||
// Breadcrumb entries for admin app.
|
||||
const breadcrumbEntries = computed(() => {
|
||||
const entries = [
|
||||
{ label: 'Auth', href: '/auth/' },
|
||||
{ label: 'Admin', href: '/auth/admin/' }
|
||||
]
|
||||
// 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_uuid) || null
|
||||
}
|
||||
const orgToShow = selectedOrg.value || orgForUser
|
||||
if (orgToShow) {
|
||||
entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` })
|
||||
}
|
||||
if (selectedUser.value) {
|
||||
entries.push({ label: selectedUser.value.display_name || 'User', href: `#user/${selectedUser.value.uuid}` })
|
||||
}
|
||||
return entries
|
||||
})
|
||||
|
||||
watch(selectedUser, async (u) => {
|
||||
if (!u) { userDetail.value = null; return }
|
||||
try {
|
||||
const res = await fetch(`/auth/admin/orgs/${u.org_uuid}/users/${u.uuid}`)
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
userDetail.value = data
|
||||
} catch (e) {
|
||||
userDetail.value = { error: e.message }
|
||||
}
|
||||
})
|
||||
|
||||
const showRegModal = ref(false)
|
||||
function generateUserRegistrationLink(u) {
|
||||
showRegModal.value = true
|
||||
}
|
||||
|
||||
function onLinkCopied() {
|
||||
authStore.showMessage('Link copied to clipboard!')
|
||||
}
|
||||
|
||||
function copy(text) {
|
||||
if (!text) return
|
||||
navigator.clipboard.writeText(text)
|
||||
.catch(()=>{})
|
||||
}
|
||||
|
||||
function permissionDisplayName(id) {
|
||||
return permissions.value.find(p => p.id === id)?.display_name || id
|
||||
}
|
||||
|
||||
async function toggleRolePermission(role, permId, checked) {
|
||||
// Build next permission list
|
||||
const has = role.permissions.includes(permId)
|
||||
if (checked && has) return
|
||||
if (!checked && !has) return
|
||||
const next = checked ? [...role.permissions, permId] : role.permissions.filter(p => p !== permId)
|
||||
// Optimistic update
|
||||
const prev = [...role.permissions]
|
||||
role.permissions = next
|
||||
try {
|
||||
const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: role.display_name, permissions: next })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to update role permission')
|
||||
role.permissions = prev // revert
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(type, data) { dialog.value = { type, data, busy: false, error: '' } }
|
||||
function closeDialog() { dialog.value = { type: null, data: null, busy: false, error: '' } }
|
||||
|
||||
async function onUserNameSaved() {
|
||||
await loadOrgs()
|
||||
if (selectedUser.value) {
|
||||
try {
|
||||
const r = await fetch(`/auth/admin/orgs/${selectedUser.value.org_uuid}/users/${selectedUser.value.uuid}`)
|
||||
const jd = await r.json()
|
||||
if (!r.ok || jd.detail) throw new Error(jd.detail || 'Reload failed')
|
||||
userDetail.value = jd
|
||||
} catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') }
|
||||
}
|
||||
authStore.showMessage('User renamed', 'success', 1500)
|
||||
}
|
||||
|
||||
async function submitDialog() {
|
||||
if (!dialog.value.type || dialog.value.busy) return
|
||||
dialog.value.busy = true; dialog.value.error = ''
|
||||
try {
|
||||
const t = dialog.value.type
|
||||
if (t === 'org-create') {
|
||||
const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||
const res = await fetch('/auth/admin/orgs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: [] }) })
|
||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await Promise.all([loadOrgs(), loadPermissions()])
|
||||
} else if (t === 'org-update') {
|
||||
const { org } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: org.permissions }) })
|
||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
||||
} else if (t === 'role-create') {
|
||||
const { org } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/roles`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: [] }) })
|
||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
||||
} else if (t === 'role-update') {
|
||||
const { role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||
const permsCsv = dialog.value.data.perms || ''
|
||||
const perms = permsCsv.split(',').map(s=>s.trim()).filter(Boolean)
|
||||
const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: perms }) })
|
||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
||||
} else if (t === 'user-create') {
|
||||
const { org, role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/users`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, role: role.display_name }) })
|
||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
||||
} else if (t === 'perm-display') {
|
||||
const { permission } = dialog.value.data; const display = dialog.value.data.display_name?.trim(); if (!display) throw new Error('Display name required')
|
||||
const params = new URLSearchParams({ permission_id: permission.id, display_name: display })
|
||||
const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'PUT' })
|
||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadPermissions()
|
||||
} else if (t === 'confirm') {
|
||||
const action = dialog.value.data.action; if (action) await action()
|
||||
}
|
||||
closeDialog()
|
||||
} catch (e) {
|
||||
dialog.value.error = e.message || 'Error'
|
||||
} finally { dialog.value.busy = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<h1>{{ pageHeading }}</h1>
|
||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
||||
<div v-if="loading">Loading…</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<div v-else>
|
||||
<div v-if="!info?.authenticated">
|
||||
<p>You must be authenticated.</p>
|
||||
</div>
|
||||
<div v-else-if="!(info?.is_global_admin || info?.is_org_admin)">
|
||||
<p>Insufficient permissions.</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
|
||||
|
||||
|
||||
<div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card">
|
||||
<h2>Organizations</h2>
|
||||
<div class="actions">
|
||||
<button @click="createOrg" v-if="info.is_global_admin">+ Create Org</button>
|
||||
</div>
|
||||
<table class="org-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Roles</th>
|
||||
<th>Members</th>
|
||||
<th v-if="info.is_global_admin">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="o in orgs" :key="o.uuid">
|
||||
<td><a href="#org/{{o.uuid}}" @click.prevent="openOrg(o)">{{ o.display_name }}</a></td>
|
||||
<td>{{ o.roles.length }}</td>
|
||||
<td>{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td>
|
||||
<td v-if="info.is_global_admin">
|
||||
<button @click="updateOrg(o)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
||||
<button @click="deleteOrg(o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization">❌</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="selectedUser" class="card user-detail">
|
||||
<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"
|
||||
:loading="loading"
|
||||
:org-display-name="userDetail.org.display_name"
|
||||
:role-name="userDetail.role"
|
||||
:update-endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
|
||||
@saved="onUserNameSaved"
|
||||
/>
|
||||
<div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
|
||||
<template v-if="userDetail && !userDetail.error">
|
||||
<h3 class="cred-title">Registered Passkeys</h3>
|
||||
<CredentialList :credentials="userDetail.credentials" :aaguid-info="userDetail.aaguid_info" />
|
||||
</template>
|
||||
<div class="actions">
|
||||
<button @click="generateUserRegistrationLink(selectedUser)">Generate Registration Token</button>
|
||||
<button @click="goOverview" v-if="info.is_global_admin" class="icon-btn" title="Overview">🏠</button>
|
||||
<button @click="openOrg(selectedOrg)" v-if="selectedOrg" class="icon-btn" title="Back to Org">↩️</button>
|
||||
</div>
|
||||
<p class="matrix-hint muted">Use the token dialog to register a new credential for the member.</p>
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegModal"
|
||||
:endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
|
||||
:auto-copy="false"
|
||||
@close="showRegModal = false"
|
||||
@copied="onLinkCopied"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-else-if="selectedOrg" class="card">
|
||||
<h2 class="org-title" :title="selectedOrg.uuid">
|
||||
<span class="org-name">{{ selectedOrg.display_name }}</span>
|
||||
<button @click="updateOrg(selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
||||
</h2>
|
||||
<div class="org-actions"></div>
|
||||
|
||||
<div class="matrix-wrapper">
|
||||
<div class="matrix-scroll">
|
||||
<div
|
||||
class="perm-matrix-grid"
|
||||
:style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + selectedOrg.roles.map(()=> '2.2rem').join(' ') + ' 2.2rem' }"
|
||||
>
|
||||
|
||||
<div class="grid-head perm-head">Permission</div>
|
||||
<div
|
||||
v-for="r in selectedOrg.roles"
|
||||
:key="'head-' + r.uuid"
|
||||
class="grid-head role-head"
|
||||
:title="r.display_name"
|
||||
>
|
||||
<span>{{ r.display_name }}</span>
|
||||
</div>
|
||||
<div class="grid-head role-head add-role-head" title="Add role" @click="createRole(selectedOrg)" role="button">➕</div>
|
||||
|
||||
|
||||
<template v-for="pid in selectedOrg.permissions" :key="pid">
|
||||
<div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div>
|
||||
<div
|
||||
v-for="r in selectedOrg.roles"
|
||||
:key="r.uuid + '-' + pid"
|
||||
class="matrix-cell"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="r.permissions.includes(pid)"
|
||||
@change="e => toggleRolePermission(r, pid, e.target.checked)"
|
||||
/>
|
||||
</div>
|
||||
<div class="matrix-cell add-role-cell" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<p class="matrix-hint muted">Toggle which permissions each role grants.</p>
|
||||
</div>
|
||||
<div class="roles-grid">
|
||||
<div
|
||||
v-for="r in selectedOrg.roles"
|
||||
:key="r.uuid"
|
||||
class="role-column"
|
||||
@dragover="onRoleDragOver"
|
||||
@drop="e => onRoleDrop(e, selectedOrg, r)"
|
||||
>
|
||||
<div class="role-header">
|
||||
<strong class="role-name" :title="r.uuid">
|
||||
<span>{{ r.display_name }}</span>
|
||||
<button @click="updateRole(r)" class="icon-btn" aria-label="Edit role" title="Edit role">✏️</button>
|
||||
</strong>
|
||||
<div class="role-actions">
|
||||
<button @click="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">
|
||||
<li
|
||||
v-for="u in r.users"
|
||||
:key="u.uuid"
|
||||
class="user-chip"
|
||||
draggable="true"
|
||||
@dragstart="e => onUserDragStart(e, u, selectedOrg.uuid)"
|
||||
@click="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">
|
||||
<p class="empty-text muted">No members</p>
|
||||
<button @click="deleteRole(r)" class="icon-btn delete-icon" aria-label="Delete empty role" title="Delete role">❌</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card">
|
||||
<h2>All Permissions</h2>
|
||||
<div class="actions">
|
||||
<button v-if="!showCreatePermission" @click="showCreatePermission = true">+ Create Permission</button>
|
||||
<form v-else class="inline-form" @submit.prevent="submitCreatePermission">
|
||||
<input v-model="newPermId" @input="sanitizeNewId" required :pattern="PERMISSION_ID_PATTERN" placeholder="permission id" title="Allowed: A-Za-z0-9:._~-" />
|
||||
<input v-model="newPermName" required placeholder="display name" />
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" @click="cancelCreatePermission">Cancel</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="permission-grid">
|
||||
<div class="perm-grid-head">Permission</div>
|
||||
<div class="perm-grid-head">Orgs</div>
|
||||
<div class="perm-grid-head center">Members</div>
|
||||
<div class="perm-grid-head center">Actions</div>
|
||||
<template v-for="p in [...permissions].sort((a,b)=> a.id.localeCompare(b.id))" :key="p.id">
|
||||
<div class="perm-cell perm-name" :title="p.id">
|
||||
<div class="perm-title-line">{{ p.display_name }}</div>
|
||||
<div class="perm-id-line muted">{{ p.id }}</div>
|
||||
</div>
|
||||
<div class="perm-cell perm-orgs" :title="permissionSummary[p.id]?.orgs?.map(o=>o.display_name).join(', ') || ''">
|
||||
<template v-if="permissionSummary[p.id]">
|
||||
<span class="org-pill" v-for="o in permissionSummary[p.id].orgs" :key="o.uuid">
|
||||
{{ o.display_name }}
|
||||
<button class="pill-x" @click.stop="detachPermissionFromOrg(p.id, o.uuid)" aria-label="Remove">×</button>
|
||||
</span>
|
||||
</template>
|
||||
<span class="org-add-wrapper">
|
||||
<button
|
||||
v-if="availableOrgsForPermission(p.id).length && addingOrgForPermission !== p.id"
|
||||
class="add-org-btn"
|
||||
@click.stop="addingOrgForPermission = p.id"
|
||||
aria-label="Add organization"
|
||||
title="Add organization"
|
||||
>➕</button>
|
||||
<div
|
||||
v-if="addingOrgForPermission === p.id"
|
||||
class="org-add-menu"
|
||||
tabindex="0"
|
||||
@keydown.escape.stop.prevent="addingOrgForPermission = null"
|
||||
>
|
||||
<div class="org-add-list">
|
||||
<button
|
||||
v-for="o in availableOrgsForPermission(p.id)"
|
||||
:key="o.uuid"
|
||||
class="org-add-item"
|
||||
@click.stop="attachPermissionToOrg(p.id, o.uuid); addingOrgForPermission = null"
|
||||
>{{ o.display_name }}</button>
|
||||
</div>
|
||||
<div class="org-add-footer">
|
||||
<button class="org-add-cancel" @click.stop="addingOrgForPermission = null" aria-label="Cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div class="perm-cell perm-users center">{{ permissionSummary[p.id]?.userCount || 0 }}</div>
|
||||
<div class="perm-cell perm-actions center">
|
||||
<div class="perm-actions-inner" :class="{ editing: editingPermId === p.id }">
|
||||
<div class="actions-view">
|
||||
<button @click="renamePermissionDisplay(p)" class="icon-btn" aria-label="Change display name" title="Change display name">✏️</button>
|
||||
<button @click="startRenamePermissionId(p)" class="icon-btn" aria-label="Change id" title="Change id">🆔</button>
|
||||
<button @click="deletePermission(p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission">❌</button>
|
||||
</div>
|
||||
<form class="inline-id-form overlay" @submit.prevent="submitRenamePermissionId(p)">
|
||||
<input v-model="renameIdValue" @input="sanitizeRenameId" required :pattern="PERMISSION_ID_PATTERN" class="id-input" title="Allowed: A-Za-z0-9:._~-" />
|
||||
<button type="submit" class="icon-btn" aria-label="Save">✔</button>
|
||||
<button type="button" class="icon-btn" @click="cancelRenameId" aria-label="Cancel">✖</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StatusMessage />
|
||||
<div v-if="dialog.type" class="modal-overlay" @keydown.esc.prevent.stop="closeDialog" tabindex="-1">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<h3 class="modal-title">
|
||||
<template v-if="dialog.type==='org-create'">Create Organization</template>
|
||||
<template v-else-if="dialog.type==='org-update'">Rename Organization</template>
|
||||
<template v-else-if="dialog.type==='role-create'">Create Role</template>
|
||||
<template v-else-if="dialog.type==='role-update'">Edit Role</template>
|
||||
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
|
||||
<template v-else-if="dialog.type==='perm-display'">Edit Permission Display</template>
|
||||
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||||
</h3>
|
||||
<form @submit.prevent="submitDialog" class="modal-form">
|
||||
<template v-if="dialog.type==='org-create' || dialog.type==='org-update'">
|
||||
<label>Name
|
||||
<input v-model="dialog.data.name" :placeholder="dialog.type==='org-update'? dialog.data.org.display_name : 'Organization name'" required />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='role-create'">
|
||||
<label>Role Name
|
||||
<input v-model="dialog.data.name" placeholder="Role name" required />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='role-update'">
|
||||
<label>Role Name
|
||||
<input v-model="dialog.data.name" :placeholder="dialog.data.role.display_name" required />
|
||||
</label>
|
||||
<label>Permissions (comma separated)
|
||||
<textarea v-model="dialog.data.perms" rows="2" placeholder="perm:a, perm:b"></textarea>
|
||||
</label>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='user-create'">
|
||||
<p class="small muted">Role: {{ dialog.data.role.display_name }}</p>
|
||||
<label>Display Name
|
||||
<input v-model="dialog.data.name" placeholder="User display name" required />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='perm-display'">
|
||||
<p class="small muted">ID: {{ dialog.data.permission.id }}</p>
|
||||
<label>Display Name
|
||||
<input v-model="dialog.data.display_name" :placeholder="dialog.data.permission.display_name" required />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='confirm'">
|
||||
<p>{{ dialog.data.message }}</p>
|
||||
</template>
|
||||
<div v-if="dialog.error" class="error small">{{ dialog.error }}</div>
|
||||
<div class="modal-actions">
|
||||
<button type="submit" :disabled="dialog.busy">{{ dialog.type==='confirm' ? 'OK' : 'Save' }}</button>
|
||||
<button type="button" @click="closeDialog" :disabled="dialog.busy">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container { max-width: 960px; margin: 2rem auto; padding: 0 1rem; }
|
||||
.subtitle { color: #888 }
|
||||
.card { margin: 1rem 0; padding: 1rem; border: 1px solid #eee; border-radius: 8px; }
|
||||
.error { color: #a00 }
|
||||
.actions { margin-bottom: .5rem }
|
||||
.org { border-top: 1px dashed #eee; padding: .5rem 0 }
|
||||
.org-header { display: flex; gap: .5rem; align-items: baseline }
|
||||
.user-item { display: flex; gap: .5rem; margin: .15rem 0 }
|
||||
.users-table { width: 100%; border-collapse: collapse; margin-top: .25rem; }
|
||||
.users-table th, .users-table td { padding: .25rem .4rem; text-align: left; border-bottom: 1px solid #eee; font-weight: normal; }
|
||||
.users-table th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: #555; }
|
||||
.users-table tbody tr:hover { background: #fafafa; }
|
||||
.org-actions, .role-actions, .perm-actions { display: flex; gap: .5rem; margin: .25rem 0 }
|
||||
.muted { color: #666 }
|
||||
.small { font-size: .9em }
|
||||
.pill-list { display: flex; flex-wrap: wrap; gap: .25rem }
|
||||
.pill { background: #f3f3f3; border: 1px solid #e2e2e2; border-radius: 999px; padding: .1rem .5rem; display: inline-flex; align-items: center; gap: .25rem }
|
||||
.pill-x { background: transparent; border: none; color: #900; cursor: pointer }
|
||||
button { padding: .25rem .5rem; border-radius: 6px; border: 1px solid #ddd; background: #fff; cursor: pointer }
|
||||
button:hover { background: #f7f7f7 }
|
||||
/* Avoid global button 100% width from frontend main styles */
|
||||
button, .perm-actions button, .org-actions button, .role-actions button { width: auto; }
|
||||
.roles-grid { display: flex; flex-wrap: wrap; gap: 1rem; align-items: stretch; padding: .5rem 0; }
|
||||
.role-column { background: #fafafa; border: 1px solid #eee; border-radius: 8px; padding: .5rem; min-width: 200px; flex: 1 1 240px; display: flex; flex-direction: column; max-width: 300px; }
|
||||
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .25rem }
|
||||
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .25rem; flex: 1 1 auto; }
|
||||
.user-chip { background: #fff; border: 1px solid #ddd; border-radius: 6px; padding: .25rem .4rem; display: flex; justify-content: space-between; gap: .5rem; cursor: grab; }
|
||||
.user-chip:active { cursor: grabbing }
|
||||
.user-chip .name { font-weight: 500 }
|
||||
.user-chip .meta { font-size: .65rem; color: #666 }
|
||||
.role-column.drag-over { outline: 2px dashed #66a; }
|
||||
.org-table { width: 100%; border-collapse: collapse; }
|
||||
.org-table th, .org-table td { padding: .4rem .5rem; border-bottom: 1px solid #eee; text-align: left; }
|
||||
.org-table th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: #555; }
|
||||
.org-table a { text-decoration: none; color: #0366d6; }
|
||||
.org-table a:hover { text-decoration: underline; }
|
||||
.nav-link { font-size: .6em; margin-left: .5rem; background: #eee; padding: .25em .6em; border-radius: 999px; border: 1px solid #ccc; text-decoration: none; }
|
||||
.nav-link:hover { background: #ddd; }
|
||||
.back-link { font-size: .5em; margin-left: .75rem; text-decoration: none; background: #eee; padding: .25em .6em; border-radius: 999px; border: 1px solid #ccc; vertical-align: middle; line-height: 1.2; }
|
||||
.back-link:hover { background: #ddd; }
|
||||
.matrix-wrapper { margin: 1rem 0; text-align: left; }
|
||||
.matrix-scroll { overflow-x: auto; text-align: left; }
|
||||
.perm-matrix-grid { display: inline-grid; gap: 0; align-items: stretch; margin-right: 4rem; }
|
||||
.perm-matrix-grid > * { background: #fff; border: none; padding: .35rem .4rem; font-size: .75rem; }
|
||||
.perm-matrix-grid .grid-head { background: transparent; border: none; font-size: .65rem; letter-spacing: .05em; font-weight: 600; text-transform: uppercase; display: flex; justify-content: center; align-items: flex-end; padding-bottom: .25rem; }
|
||||
.perm-matrix-grid .perm-head { justify-content: flex-start; align-items: flex-end; }
|
||||
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: .6rem; line-height: 1; }
|
||||
.perm-matrix-grid .perm-name { font-weight: 500; white-space: nowrap; text-align: left; }
|
||||
.perm-matrix-grid .matrix-cell { display: flex; justify-content: center; align-items: center; }
|
||||
.perm-matrix-grid .matrix-cell input { cursor: pointer; }
|
||||
.matrix-hint { font-size: .7rem; margin-top: .25rem; }
|
||||
/* Add role column styles */
|
||||
.add-role-head { cursor: pointer; color: #2a6; font-size: 1rem; display:flex; justify-content:center; align-items:flex-end; }
|
||||
.add-role-head:hover { color:#1c4; }
|
||||
/* Removed add-role placeholder styles */
|
||||
/* Inline organization title with icon */
|
||||
.org-title { display: flex; align-items: center; gap: .4rem; }
|
||||
.org-title .org-name { flex: 0 1 auto; }
|
||||
/* Plus button for adding users */
|
||||
.plus-btn { background: none; border: none; font-size: 1.15rem; line-height: 1; padding: 0 .1rem; cursor: pointer; opacity: .6; }
|
||||
.plus-btn:hover, .plus-btn:focus { opacity: 1; outline: none; }
|
||||
.plus-btn:focus-visible { outline: 2px solid #555; outline-offset: 2px; }
|
||||
.empty-role { display: flex; flex-direction: column; gap: .4rem; align-items: flex-start; padding: .35rem .25rem; /* removed flex grow & width for natural size */ }
|
||||
.empty-role .empty-text { font-size: .7rem; margin: 0; }
|
||||
.delete-icon { color: #c00; }
|
||||
.delete-icon:hover, .delete-icon:focus { color: #ff0000; }
|
||||
.user-detail .user-link-box { margin-top: .75rem; font-size: .7rem; background: #fff; border: 1px dashed #ccc; padding: .5rem; border-radius: 6px; cursor: pointer; word-break: break-all; }
|
||||
.user-detail .user-link-box:hover { background: #f9f9f9; }
|
||||
.user-detail .user-link-box .expires { font-size: .6rem; margin-top: .25rem; color: #555; }
|
||||
/* Minimal icon button for rename/edit actions */
|
||||
.icon-btn { background: none; border: none; padding: 0 .15rem; margin-left: .15rem; cursor: pointer; font-size: .8rem; line-height: 1; opacity: .55; vertical-align: middle; }
|
||||
.icon-btn:hover, .icon-btn:focus { opacity: .95; outline: none; }
|
||||
.icon-btn:focus-visible { outline: 2px solid #555; outline-offset: 2px; }
|
||||
.icon-btn:active { transform: translateY(1px); }
|
||||
.org-title { display: flex; align-items: baseline; gap: .25rem; }
|
||||
.role-name { display: inline-flex; align-items: center; gap: .15rem; font-weight: 600; }
|
||||
.perm-name-line { display: flex; align-items: center; gap: .15rem; }
|
||||
.user-meta { margin-top: .25rem; }
|
||||
.cred-title { margin-top: .75rem; font-size: .85rem; }
|
||||
.cred-list { list-style: none; padding: 0; margin: .25rem 0 .5rem; display: flex; flex-direction: column; gap: .35rem; }
|
||||
.cred-item { background: #fff; border: 1px solid #eee; border-radius: 6px; padding: .35rem .5rem; font-size: .65rem; }
|
||||
.cred-line { display: flex; flex-direction: column; gap: .15rem; }
|
||||
.cred-line .dates { color: #555; font-size: .6rem; }
|
||||
/* Permission grid */
|
||||
.permission-grid { display: grid; grid-template-columns: minmax(220px,2fr) minmax(160px,3fr) 70px 90px; gap: 2px; margin-top: .5rem; }
|
||||
.permission-grid .perm-grid-head { font-size: .6rem; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; padding: .35rem .4rem; background: #f3f3f3; border: 1px solid #e1e1e1; }
|
||||
.permission-grid .perm-cell { background: #fff; border: 1px solid #eee; padding: .35rem .4rem; font-size: .7rem; display: flex; align-items: center; gap: .4rem; }
|
||||
.permission-grid .perm-name { flex-direction: row; flex-wrap: wrap; }
|
||||
.permission-grid .perm-name { flex-direction: column; align-items: flex-start; gap:2px; }
|
||||
.permission-grid .perm-title-line { font-weight:600; line-height:1.1; }
|
||||
.permission-grid .perm-id-line { font-size:.55rem; line-height:1.1; word-break:break-all; }
|
||||
.permission-grid .center { justify-content: center; }
|
||||
.permission-grid .perm-actions { gap: .25rem; }
|
||||
.permission-grid .perm-actions .icon-btn { font-size: .9rem; }
|
||||
/* Inline edit overlay to avoid layout shift */
|
||||
.perm-actions-inner { position: relative; display:flex; width:100%; justify-content:center; }
|
||||
.perm-actions-inner .inline-id-form.overlay { position:absolute; inset:0; display:none; align-items:center; justify-content:center; gap:.25rem; background:rgba(255,255,255,.9); backdrop-filter:blur(2px); padding:0 .15rem; }
|
||||
.perm-actions-inner.editing .inline-id-form.overlay { display:inline-flex; }
|
||||
.perm-actions-inner.editing .actions-view { visibility:hidden; }
|
||||
/* Inline forms */
|
||||
.inline-form, .inline-id-form { display:inline-flex; gap:.25rem; align-items:center; }
|
||||
.inline-form input, .inline-id-form input { padding:.25rem .4rem; font-size:.6rem; border:1px solid #ccc; border-radius:4px; }
|
||||
.inline-form button, .inline-id-form button { font-size:.6rem; padding:.3rem .5rem; }
|
||||
.inline-id-form .id-input { width:120px; }
|
||||
/* Modal */
|
||||
.modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.4); display:flex; justify-content:center; align-items:flex-start; padding-top:8vh; z-index:200; }
|
||||
.modal { background:#fff; border-radius:10px; padding:1rem 1.1rem; width: min(420px, 90%); box-shadow:0 10px 30px rgba(0,0,0,.25); animation:pop .18s ease; }
|
||||
@keyframes pop { from { transform:translateY(10px); opacity:0 } to { transform:translateY(0); opacity:1 } }
|
||||
.modal-title { margin:0 0 .65rem; font-size:1rem; }
|
||||
.modal-form { display:flex; flex-direction:column; gap:.65rem; }
|
||||
.modal-form label { display:flex; flex-direction:column; font-size:.65rem; gap:.25rem; font-weight:600; }
|
||||
.modal-form input, .modal-form textarea { border:1px solid #ccc; border-radius:6px; padding:.45rem .55rem; font-size:.7rem; font-weight:400; font-family:inherit; }
|
||||
.modal-form textarea { resize:vertical; }
|
||||
.modal-actions { display:flex; gap:.5rem; justify-content:flex-end; margin-top:.25rem; }
|
||||
.modal-actions button { font-size:.65rem; }
|
||||
/* Org pill editing */
|
||||
.perm-orgs { flex-wrap: wrap; gap: .25rem; }
|
||||
.perm-orgs .org-pill { background:#eef4ff; border:1px solid #d0dcf0; padding:2px 6px; border-radius:999px; font-size:.55rem; display:inline-flex; align-items:center; gap:4px; }
|
||||
.perm-orgs .org-pill .pill-x { background:none; border:none; cursor:pointer; font-size:.7rem; line-height:1; padding:0; margin:0; color:#555; }
|
||||
.perm-orgs .org-pill .pill-x:hover { color:#c00; }
|
||||
.add-org-btn { background:none; border:none; cursor:pointer; font-size:.7rem; padding:0 2px; line-height:1; opacity:.55; display:inline; }
|
||||
.add-org-btn:hover, .add-org-btn:focus { opacity:1; }
|
||||
.add-org-btn:focus-visible { outline:2px solid #555; outline-offset:2px; }
|
||||
.org-add-wrapper { position:relative; display:inline-block; }
|
||||
.org-add-menu { position:absolute; top:100%; left:0; z-index:20; margin-top:4px; min-width:160px; background:#fff; border:1px solid #e2e6ea; border-radius:6px; padding:.3rem .35rem; box-shadow:0 4px 10px rgba(0,0,0,.08); display:flex; flex-direction:column; gap:.25rem; font-size:.6rem; }
|
||||
.org-add-menu:before { content:""; position:absolute; top:-5px; left:10px; width:8px; height:8px; background:#fff; border-left:1px solid #e2e6ea; border-top:1px solid #e2e6ea; transform:rotate(45deg); }
|
||||
.org-add-list { display:flex; flex-direction:column; gap:0; max-height:180px; overflow-y:auto; scrollbar-width:thin; }
|
||||
.org-add-item { background:transparent; border:none; padding:.25rem .4rem; font-size:.6rem; border-radius:4px; cursor:pointer; line-height:1.1; text-align:left; width:100%; color:#222; }
|
||||
.org-add-item:hover, .org-add-item:focus { background:#f2f5f9; }
|
||||
.org-add-item:active { background:#e6ebf0; }
|
||||
.org-add-footer { margin-top:.25rem; display:flex; justify-content:flex-end; }
|
||||
.org-add-cancel { background:transparent; border:none; font-size:.55rem; padding:.15rem .35rem; cursor:pointer; color:#666; border-radius:4px; }
|
||||
.org-add-cancel:hover, .org-add-cancel:focus { background:#f2f5f9; color:#222; }
|
||||
.org-add-cancel:active { background:#e6ebf0; }
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import NameEditForm from '@/components/NameEditForm.vue'
|
||||
|
||||
const props = defineProps({
|
||||
dialog: Object,
|
||||
PERMISSION_ID_PATTERN: String
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submitDialog', 'closeDialog'])
|
||||
|
||||
const nameInput = ref(null)
|
||||
const displayNameInput = ref(null)
|
||||
|
||||
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
|
||||
|
||||
watch(() => props.dialog.type, (newType) => {
|
||||
if (newType === 'org-create') {
|
||||
nextTick(() => {
|
||||
nameInput.value?.focus()
|
||||
})
|
||||
} else if (newType === 'perm-display' || newType === 'perm-create') {
|
||||
nextTick(() => {
|
||||
displayNameInput.value?.focus()
|
||||
if (newType === 'perm-display') {
|
||||
displayNameInput.value?.select()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-if="dialog.type" @close="$emit('closeDialog')">
|
||||
<h3 class="modal-title">
|
||||
<template v-if="dialog.type==='org-create'">Create Organization</template>
|
||||
<template v-else-if="dialog.type==='org-update'">Rename Organization</template>
|
||||
<template v-else-if="dialog.type==='role-create'">Create Role</template>
|
||||
<template v-else-if="dialog.type==='role-update'">Edit Role</template>
|
||||
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
|
||||
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
|
||||
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission Display' }}</template>
|
||||
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||||
</h3>
|
||||
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
||||
<template v-if="dialog.type==='org-create'">
|
||||
<label>Name
|
||||
<input ref="nameInput" v-model="dialog.data.name" required />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='org-update'">
|
||||
<NameEditForm
|
||||
label="Organization Name"
|
||||
v-model="dialog.data.name"
|
||||
:busy="dialog.busy"
|
||||
:error="dialog.error"
|
||||
@cancel="$emit('closeDialog')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='role-create'">
|
||||
<label>Role Name
|
||||
<input v-model="dialog.data.name" placeholder="Role name" required />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='role-update'">
|
||||
<NameEditForm
|
||||
label="Role Name"
|
||||
v-model="dialog.data.name"
|
||||
:busy="dialog.busy"
|
||||
:error="dialog.error"
|
||||
@cancel="$emit('closeDialog')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='user-create'">
|
||||
<p class="small muted">Role: {{ dialog.data.role.display_name }}</p>
|
||||
<label>Display Name
|
||||
<input v-model="dialog.data.name" placeholder="User display name" required />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='user-update-name'">
|
||||
<NameEditForm
|
||||
label="Display Name"
|
||||
v-model="dialog.data.name"
|
||||
:busy="dialog.busy"
|
||||
:error="dialog.error"
|
||||
@cancel="$emit('closeDialog')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">
|
||||
<label>Display Name
|
||||
<input ref="displayNameInput" v-model="dialog.data.display_name" required />
|
||||
</label>
|
||||
<label>Permission ID
|
||||
<input v-model="dialog.data.id" :placeholder="dialog.type === 'perm-create' ? 'yourapp:login' : dialog.data.permission.id" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" />
|
||||
</label>
|
||||
<p class="small muted">The permission ID is used for permission checks in the application. Changing it may break deployed applications that reference this permission.</p>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@click="$emit('closeDialog')"
|
||||
:disabled="dialog.busy"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
:disabled="dialog.busy"
|
||||
>
|
||||
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.error { color: var(--color-danger-text); }
|
||||
.small { font-size: 0.9rem; }
|
||||
.muted { color: var(--color-text-muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
selectedOrg: Object,
|
||||
permissions: Array
|
||||
})
|
||||
|
||||
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'onRoleDragOver', 'onRoleDrop', 'onUserDragStart'])
|
||||
|
||||
const sortedRoles = computed(() => {
|
||||
return [...props.selectedOrg.roles].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)
|
||||
})
|
||||
})
|
||||
|
||||
function permissionDisplayName(id) {
|
||||
return props.permissions.find(p => p.id === id)?.display_name || id
|
||||
}
|
||||
|
||||
function toggleRolePermission(role, pid, checked) {
|
||||
emit('toggleRolePermission', role, pid, checked)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2 class="org-title" :title="selectedOrg.uuid">
|
||||
<span class="org-name">{{ selectedOrg.display_name }}</span>
|
||||
<button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
||||
</h2>
|
||||
|
||||
<div class="matrix-wrapper">
|
||||
<div class="matrix-scroll">
|
||||
<div
|
||||
class="perm-matrix-grid"
|
||||
:style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + sortedRoles.map(()=> '2.2rem').join(' ') + ' 2.2rem' }"
|
||||
>
|
||||
<div class="grid-head perm-head">Permission</div>
|
||||
<div
|
||||
v-for="r in sortedRoles"
|
||||
:key="'head-' + r.uuid"
|
||||
class="grid-head role-head"
|
||||
:title="r.display_name"
|
||||
>
|
||||
<span>{{ r.display_name }}</span>
|
||||
</div>
|
||||
<div class="grid-head role-head add-role-head" title="Add role" @click="$emit('createRole', selectedOrg)" role="button">➕</div>
|
||||
|
||||
<template v-for="pid in selectedOrg.permissions" :key="pid">
|
||||
<div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div>
|
||||
<div
|
||||
v-for="r in sortedRoles"
|
||||
:key="r.uuid + '-' + pid"
|
||||
class="matrix-cell"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="r.permissions.includes(pid)"
|
||||
@change="e => toggleRolePermission(r, pid, e.target.checked)"
|
||||
/>
|
||||
</div>
|
||||
<div class="matrix-cell add-role-cell" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<p class="matrix-hint muted">Toggle which permissions each role grants.</p>
|
||||
</div>
|
||||
<div class="roles-grid">
|
||||
<div
|
||||
v-for="r in sortedRoles"
|
||||
:key="r.uuid"
|
||||
class="role-column"
|
||||
@dragover="$emit('onRoleDragOver', $event)"
|
||||
@drop="e => $emit('onRoleDrop', e, selectedOrg, r)"
|
||||
>
|
||||
<div class="role-header">
|
||||
<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>
|
||||
</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">
|
||||
<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"
|
||||
draggable="true"
|
||||
@dragstart="e => $emit('onUserDragStart', e, u, selectedOrg.uuid)"
|
||||
@click="$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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</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; }
|
||||
.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); }
|
||||
.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: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-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; }
|
||||
.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; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
info: Object,
|
||||
orgs: Array,
|
||||
permissions: Array,
|
||||
permissionSummary: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay'])
|
||||
|
||||
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
|
||||
const nameCompare = a.display_name.localeCompare(b.display_name)
|
||||
return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid)
|
||||
}))
|
||||
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.id.localeCompare(b.id)))
|
||||
|
||||
function permissionDisplayName(id) {
|
||||
return props.permissions.find(p => p.id === id)?.display_name || id
|
||||
}
|
||||
|
||||
function getRoleNames(org) {
|
||||
return org.roles
|
||||
.slice()
|
||||
.sort((a, b) => a.display_name.localeCompare(b.display_name))
|
||||
.map(r => r.display_name)
|
||||
.join(', ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="permissions-section">
|
||||
<h2>{{ info.is_global_admin ? 'Organizations' : 'Your Organizations' }}</h2>
|
||||
<div class="actions">
|
||||
<button v-if="info.is_global_admin" @click="$emit('createOrg')">+ Create Org</button>
|
||||
</div>
|
||||
<table class="org-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Roles</th>
|
||||
<th>Members</th>
|
||||
<th v-if="info.is_global_admin">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
<button v-if="info.is_global_admin || info.is_org_admin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
||||
</td>
|
||||
<td class="role-names">{{ getRoleNames(o) }}</td>
|
||||
<td class="center">{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td>
|
||||
<td v-if="info.is_global_admin" class="center">
|
||||
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization">❌</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="info.is_global_admin" class="permissions-section">
|
||||
<h2>Permissions</h2>
|
||||
<div class="matrix-wrapper">
|
||||
<div class="matrix-scroll">
|
||||
<div
|
||||
class="perm-matrix-grid"
|
||||
:style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + sortedOrgs.map(()=> '2.2rem').join(' ') }"
|
||||
>
|
||||
<div class="grid-head perm-head">Permission</div>
|
||||
<div
|
||||
v-for="o in sortedOrgs"
|
||||
:key="'head-' + o.uuid"
|
||||
class="grid-head org-head"
|
||||
:title="o.display_name"
|
||||
>
|
||||
<span>{{ o.display_name }}</span>
|
||||
</div>
|
||||
|
||||
<template v-for="p in sortedPermissions" :key="p.id">
|
||||
<div class="perm-name" :title="p.id">
|
||||
<span class="display-text">{{ p.display_name }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="o in sortedOrgs"
|
||||
:key="o.uuid + '-' + p.id"
|
||||
class="matrix-cell"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="o.permissions.includes(p.id)"
|
||||
@change="e => $emit('toggleOrgPermission', o, p.id, e.target.checked)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<p class="matrix-hint muted">Toggle which permissions each organization can grant to its members.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button v-if="info.is_global_admin" @click="$emit('openDialog', 'perm-create', { display_name: '', id: '' })">+ Create Permission</button>
|
||||
</div>
|
||||
<table class="org-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Permission</th>
|
||||
<th scope="col" class="center">Members</th>
|
||||
<th scope="col" class="center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in sortedPermissions" :key="p.id">
|
||||
<td class="perm-name-cell">
|
||||
<div class="perm-title">
|
||||
<span class="display-text">{{ p.display_name }}</span>
|
||||
<button @click="$emit('renamePermissionDisplay', p)" class="icon-btn edit-display-btn" aria-label="Edit display name" title="Edit display name">✏️</button>
|
||||
</div>
|
||||
<div class="perm-id-info">
|
||||
<span class="id-text">{{ p.id }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="perm-members center">{{ permissionSummary[p.id]?.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>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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; }
|
||||
.org-table .role-names { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.perm-name-cell { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.perm-title { font-weight: 600; color: var(--color-heading); }
|
||||
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||
.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); }
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import SessionList from '@/components/SessionList.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiJson } from '@/utils/api'
|
||||
|
||||
const props = defineProps({
|
||||
selectedUser: Object,
|
||||
userDetail: Object,
|
||||
selectedOrg: Object,
|
||||
loading: Boolean,
|
||||
showRegModal: Boolean
|
||||
})
|
||||
|
||||
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const terminatingSessions = ref({})
|
||||
const hoveredCredentialUuid = ref(null)
|
||||
const hoveredSession = ref(null)
|
||||
|
||||
function onLinkCopied() {
|
||||
authStore.showMessage('Link copied to clipboard!')
|
||||
}
|
||||
|
||||
function handleEditName() {
|
||||
emit('editUserName', props.selectedUser)
|
||||
}
|
||||
|
||||
async function handleDelete(credential) {
|
||||
try {
|
||||
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/credentials/${credential.credential_uuid}`, { method: 'DELETE' })
|
||||
if (data.status === 'ok') {
|
||||
emit('onUserNameSaved') // Reuse to refresh user detail
|
||||
} else {
|
||||
console.error('Failed to delete credential', data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete credential error', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTerminateSession(session) {
|
||||
const sessionId = session?.id
|
||||
if (!sessionId) return
|
||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
|
||||
try {
|
||||
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
if (data.status === 'ok') {
|
||||
if (data.current_session_terminated) {
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
return
|
||||
}
|
||||
emit('refreshUserDetail') // Refresh without showing rename message
|
||||
authStore.showMessage('Session terminated', 'success', 2500)
|
||||
} else {
|
||||
authStore.showMessage(data.detail || 'Failed to terminate session', 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Terminate session error', err)
|
||||
authStore.showMessage(err.message || 'Failed to terminate session', 'error')
|
||||
} finally {
|
||||
const next = { ...terminatingSessions.value }
|
||||
delete next[sessionId]
|
||||
terminatingSessions.value = next
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-detail">
|
||||
<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"
|
||||
:loading="loading"
|
||||
:org-display-name="userDetail.org.display_name"
|
||||
:role-name="userDetail.role"
|
||||
:update-endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
|
||||
@saved="$emit('onUserNameSaved')"
|
||||
@edit-name="handleEditName"
|
||||
/>
|
||||
<div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
|
||||
<template v-if="userDetail && !userDetail.error">
|
||||
<div class="registration-actions">
|
||||
<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>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<CredentialList
|
||||
:credentials="userDetail.credentials"
|
||||
:aaguid-info="userDetail.aaguid_info"
|
||||
:allow-delete="true"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
:hovered-session-credential-uuid="hoveredSession?.credential_uuid"
|
||||
@delete="handleDelete"
|
||||
@credential-hover="hoveredCredentialUuid = $event"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<SessionList
|
||||
:sessions="userDetail.sessions || []"
|
||||
:terminating-sessions="terminatingSessions"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
:empty-message="'This user has no active sessions.'"
|
||||
:section-description="'View and manage the active sessions for this user.'"
|
||||
@terminate="handleTerminateSession"
|
||||
@session-hover="hoveredSession = $event"
|
||||
/>
|
||||
</template>
|
||||
<div class="actions ancillary-actions">
|
||||
<button v-if="selectedOrg" @click="$emit('openOrg', selectedOrg)" class="icon-btn" title="Back to Org">↩️</button>
|
||||
</div>
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegModal"
|
||||
:endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
|
||||
:auto-copy="false"
|
||||
:user-name="userDetail?.display_name || selectedUser.display_name"
|
||||
@close="$emit('closeRegModal')"
|
||||
@copied="onLinkCopied"
|
||||
/>
|
||||
</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; }
|
||||
.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>
|
||||
+642
-388
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="message-container">
|
||||
<div class="message-content">
|
||||
<h2>🔒 Access Denied</h2>
|
||||
<div class="button-row">
|
||||
<button class="btn-secondary" @click="goBack">Back</button>
|
||||
<button class="btn-primary" @click="$emit('reload')">Reload Page</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { goBack } from '@/utils/helpers'
|
||||
|
||||
defineEmits(['reload'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.message-content h2 {
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
.message-content .button-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -29,14 +29,10 @@ const crumbs = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.breadcrumbs { margin: .25rem 0 .5rem; line-height:1.2; }
|
||||
.breadcrumbs ol { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; align-items: center; }
|
||||
.breadcrumbs li { display: inline-flex; align-items: center; }
|
||||
.breadcrumbs a { text-decoration: none; color: #0366d6; padding: 0 .15rem; border-radius:4px; }
|
||||
.breadcrumbs a:hover, .breadcrumbs a:focus { text-decoration: underline; }
|
||||
.breadcrumbs .sep { color: #888; margin: 0 .1rem; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.breadcrumbs a { color: #4ea3ff; }
|
||||
.breadcrumbs .sep { color: #aaa; }
|
||||
}
|
||||
.breadcrumbs { margin: .25rem 0 .5rem; line-height:1.2; color: var(--color-text-muted); }
|
||||
.breadcrumbs ol { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: .25rem; }
|
||||
.breadcrumbs li { display: inline-flex; align-items: center; gap: .25rem; font-size: .9rem; }
|
||||
.breadcrumbs a { text-decoration: none; color: var(--color-link); padding: 0 .25rem; border-radius:4px; transition: color 0.2s ease, background 0.2s ease; }
|
||||
.breadcrumbs a:hover, .breadcrumbs a:focus-visible { text-decoration: underline; color: var(--color-link-hover); outline: none; }
|
||||
.breadcrumbs .sep { color: var(--color-text-muted); margin: 0; }
|
||||
</style>
|
||||
|
||||
@@ -2,14 +2,21 @@
|
||||
<div class="credential-list">
|
||||
<div v-if="loading"><p>Loading credentials...</p></div>
|
||||
<div v-else-if="!credentials?.length"><p>No passkeys found.</p></div>
|
||||
<div v-else>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="credential in credentials"
|
||||
:key="credential.credential_uuid"
|
||||
:class="['credential-item', { 'current-session': credential.is_current_session }]"
|
||||
:class="['credential-item', {
|
||||
'current-session': credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid,
|
||||
'is-hovered': hoveredCredentialUuid === credential.credential_uuid,
|
||||
'is-linked-session': hoveredSessionCredentialUuid === credential.credential_uuid
|
||||
}]"
|
||||
tabindex="0"
|
||||
@focusin="handleCredentialFocus(credential.credential_uuid)"
|
||||
@focusout="handleCredentialBlur($event)"
|
||||
>
|
||||
<div class="credential-header">
|
||||
<div class="credential-icon">
|
||||
<div class="item-top">
|
||||
<div class="item-icon">
|
||||
<img
|
||||
v-if="getCredentialAuthIcon(credential)"
|
||||
:src="getCredentialAuthIcon(credential)"
|
||||
@@ -20,31 +27,37 @@
|
||||
>
|
||||
<span v-else class="auth-emoji">🔑</span>
|
||||
</div>
|
||||
<div class="credential-info">
|
||||
<h4>{{ getCredentialAuthName(credential) }}</h4>
|
||||
</div>
|
||||
<div class="credential-dates">
|
||||
<span class="date-label">Created:</span>
|
||||
<span class="date-value">{{ formatDate(credential.created_at) }}</span>
|
||||
<span class="date-label" v-if="credential.last_used">Last used:</span>
|
||||
<span class="date-value" v-if="credential.last_used">{{ formatDate(credential.last_used) }}</span>
|
||||
</div>
|
||||
<div class="credential-actions" v-if="allowDelete">
|
||||
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
|
||||
<div class="item-actions">
|
||||
<span v-if="credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid" class="badge badge-current">Current</span>
|
||||
<span v-else-if="hoveredCredentialUuid === credential.credential_uuid" class="badge badge-current">Selected</span>
|
||||
<span v-else-if="hoveredSessionCredentialUuid === credential.credential_uuid" class="badge badge-current">Linked</span>
|
||||
<button
|
||||
v-if="allowDelete"
|
||||
@click="$emit('delete', credential)"
|
||||
class="btn-delete-credential"
|
||||
class="btn-card-delete"
|
||||
:disabled="credential.is_current_session"
|
||||
:title="credential.is_current_session ? 'Cannot delete current session credential' : 'Delete passkey'"
|
||||
>🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<div class="credential-dates">
|
||||
<span class="date-label">Created:</span>
|
||||
<span class="date-value">{{ formatDate(credential.created_at) }}</span>
|
||||
<span class="date-label">Last used:</span>
|
||||
<span class="date-value">{{ formatDate(credential.last_used) }}</span>
|
||||
<span class="date-label">Last verified:</span>
|
||||
<span class="date-value">{{ formatDate(credential.last_verified) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -52,8 +65,23 @@ const props = defineProps({
|
||||
aaguidInfo: { type: Object, default: () => ({}) },
|
||||
loading: { type: Boolean, default: false },
|
||||
allowDelete: { type: Boolean, default: false },
|
||||
hoveredCredentialUuid: { type: String, default: null },
|
||||
hoveredSessionCredentialUuid: { type: String, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['delete', 'credentialHover'])
|
||||
|
||||
const handleCredentialFocus = (uuid) => {
|
||||
emit('credentialHover', uuid)
|
||||
}
|
||||
|
||||
const handleCredentialBlur = (event) => {
|
||||
// Only clear if focus moved outside this element
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
emit('credentialHover', null)
|
||||
}
|
||||
}
|
||||
|
||||
const getCredentialAuthName = (credential) => {
|
||||
const info = props.aaguidInfo?.[credential.aaguid]
|
||||
return info ? info.name : 'Unknown Authenticator'
|
||||
@@ -67,18 +95,3 @@ const getCredentialAuthIcon = (credential) => {
|
||||
return info[iconKey] || null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.credential-list { display: flex; flex-direction: column; gap: .75rem; margin-top: .5rem; }
|
||||
.credential-item { border: 1px solid #ddd; border-radius: 8px; padding: .5rem .75rem; background: #fff; }
|
||||
.credential-header { display: flex; align-items: center; gap: 1rem; }
|
||||
.credential-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; }
|
||||
.auth-icon { border-radius: 6px; }
|
||||
.credential-info { flex: 1 1 auto; }
|
||||
.credential-info h4 { margin: 0; font-size: .9rem; }
|
||||
.credential-dates { display: grid; grid-auto-flow: column; gap: .4rem; font-size: .65rem; align-items: center; }
|
||||
.date-label { font-weight: 600; }
|
||||
.credential-actions { margin-left: auto; }
|
||||
.btn-delete-credential { background: none; border: none; cursor: pointer; font-size: .9rem; }
|
||||
.btn-delete-credential:disabled { opacity: .3; cursor: not-allowed; }
|
||||
</style>
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="view active">
|
||||
<section class="view-root view-root--narrow view-device-link">
|
||||
<header class="view-header">
|
||||
<h1>📱 Add Another Device</h1>
|
||||
<div class="device-link-section">
|
||||
<div class="qr-container">
|
||||
<a :href="url" id="deviceLinkText" @click="copyLink">
|
||||
<canvas id="qrCode" class="qr-code"></canvas>
|
||||
<p v-if="url">
|
||||
{{ url.replace(/^[^:]+:\/\//, '') }}
|
||||
</p>
|
||||
<p v-else>
|
||||
<em>Generating link...</em>
|
||||
</p>
|
||||
</a>
|
||||
<p>
|
||||
<strong>Scan and visit the URL on another device.</strong><br>
|
||||
<small>⚠️ Expires in 24 hours and can only be used once.</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="authStore.currentView = 'profile'" class="btn-secondary">
|
||||
Back to Profile
|
||||
</button>
|
||||
<p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p>
|
||||
</header>
|
||||
<RegistrationLinkModal
|
||||
inline
|
||||
:endpoint="'/auth/api/user/create-link'"
|
||||
:user-name="userName"
|
||||
:auto-copy="false"
|
||||
:prefix-copy-with-user-name="!!userName"
|
||||
show-close-in-inline
|
||||
@copied="onCopied"
|
||||
/>
|
||||
<div class="button-row" style="margin-top:1rem;">
|
||||
<button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import QRCode from 'qrcode/lib/browser'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const url = ref(null)
|
||||
|
||||
const copyLink = async (event) => {
|
||||
event.preventDefault()
|
||||
if (url.value) {
|
||||
await navigator.clipboard.writeText(url.value)
|
||||
authStore.showMessage('Link copied to clipboard!')
|
||||
authStore.currentView = 'profile'
|
||||
}
|
||||
const userName = ref(null)
|
||||
const onCopied = () => {
|
||||
authStore.showMessage('Link copied to clipboard!', 'success', 2500)
|
||||
authStore.currentView = 'profile'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await fetch('/auth/api/create-link', { method: 'POST' })
|
||||
const result = await response.json()
|
||||
if (result.detail) throw new Error(result.detail)
|
||||
|
||||
url.value = result.url
|
||||
|
||||
// Generate QR code
|
||||
const qrCodeElement = document.getElementById('qrCode')
|
||||
if (qrCodeElement) {
|
||||
QRCode.toCanvas(qrCodeElement, url.value, {scale: 8 }, error => {
|
||||
if (error) console.error('Failed to generate QR code:', error)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
authStore.showMessage(`Failed to create device link: ${error.message}`, 'error')
|
||||
authStore.currentView = 'profile'
|
||||
}
|
||||
// Extract optional admin-provided query parameters (?user=Name&emoji=😀)
|
||||
const params = new URLSearchParams(location.search)
|
||||
const qUser = params.get('user')
|
||||
if (qUser) userName.value = qUser.trim()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-lede {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.qr-link {
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.button-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.button-row button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<section class="view-root host-view" data-view="host-profile">
|
||||
<header class="view-header">
|
||||
<h1>{{ headingTitle }}</h1>
|
||||
<p class="view-lede">{{ subheading }}</p>
|
||||
</header>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="section-body">
|
||||
<UserBasicInfo
|
||||
v-if="user"
|
||||
:name="user.user_name"
|
||||
:visits="user.visits || 0"
|
||||
:created-at="user.created_at"
|
||||
:last-seen="user.last_seen"
|
||||
:org-display-name="orgDisplayName"
|
||||
:role-name="roleDisplayName"
|
||||
:can-edit="false"
|
||||
/>
|
||||
<p v-else class="empty-state">
|
||||
{{ initializing ? 'Loading your account…' : 'No active session found.' }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="section-body host-actions">
|
||||
<div class="button-row">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@click="goBack"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
:disabled="authStore.isLoading"
|
||||
@click="logout"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Signing out…' : 'Logout' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="authSiteUrl"
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="authStore.isLoading"
|
||||
@click="goToAuthSite"
|
||||
>
|
||||
Full Profile
|
||||
</button>
|
||||
</div>
|
||||
<p class="note"><strong>Logout</strong> from {{ currentHost }}, or access your <strong>Full Profile</strong> at {{ authSiteHost }} (you may need to sign in again).</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { goBack } from '@/utils/helpers'
|
||||
|
||||
defineProps({
|
||||
initializing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const currentHost = window.location.host
|
||||
|
||||
const user = computed(() => authStore.userInfo?.user || null)
|
||||
const orgDisplayName = computed(() => authStore.userInfo?.org?.display_name || '')
|
||||
const roleDisplayName = computed(() => authStore.userInfo?.role?.display_name || '')
|
||||
|
||||
const headingTitle = computed(() => {
|
||||
const service = authStore.settings?.rp_name
|
||||
return service ? `${service} account` : 'Account overview'
|
||||
})
|
||||
|
||||
const subheading = computed(() => {
|
||||
return `You're signed in to ${currentHost}.`
|
||||
})
|
||||
|
||||
const authSiteHost = computed(() => authStore.settings?.auth_host || '')
|
||||
const authSiteUrl = computed(() => {
|
||||
const host = authSiteHost.value
|
||||
if (!host) return ''
|
||||
let path = authStore.settings?.ui_base_path ?? '/auth/'
|
||||
if (!path.startsWith('/')) path = `/${path}`
|
||||
if (!path.endsWith('/')) path = `${path}/`
|
||||
const protocol = window.location.protocol || 'https:'
|
||||
return `${protocol}//${host}${path}`
|
||||
})
|
||||
|
||||
const goToAuthSite = () => {
|
||||
if (!authSiteUrl.value) return
|
||||
window.location.href = authSiteUrl.value
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
await authStore.logout()
|
||||
}
|
||||
</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: 0 0 auto; }
|
||||
.note { margin: 0; color: var(--color-text-muted); }
|
||||
.empty-state { margin: 0; color: var(--color-text-muted); }
|
||||
@media (max-width: 600px) {
|
||||
.host-actions .button-row { flex-direction: column; }
|
||||
.host-actions .button-row button { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="loading-container">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: String,
|
||||
default: 'Loading...'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--color-border);
|
||||
border-top: 4px solid 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;
|
||||
}
|
||||
</style>
|
||||
@@ -1,42 +0,0 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="view active">
|
||||
<h1>🔐 {{ (authStore.settings?.rp_name || 'Passkey') + ' Login' }}</h1>
|
||||
<form @submit.prevent="handleLogin">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
:disabled="authStore.isLoading"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Authenticating...' : 'Login with Your Device' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
console.log('Login button clicked')
|
||||
authStore.showMessage('Starting authentication...', 'info')
|
||||
await authStore.authenticate()
|
||||
authStore.showMessage('Authentication successful!', 'success', 2000)
|
||||
if (authStore.restrictedMode) {
|
||||
// Restricted mode: reload so the app re-mounts and selectView() applies (will become permission denied)
|
||||
location.reload()
|
||||
} else if (location.pathname === '/auth/') {
|
||||
authStore.currentView = 'profile'
|
||||
} else {
|
||||
location.reload()
|
||||
}
|
||||
} catch (error) {
|
||||
authStore.showMessage(error.message, 'error')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="modal-overlay" @keydown.esc="$emit('close')" tabindex="-1">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: transparent;
|
||||
backdrop-filter: blur(.1rem) brightness(0.7);
|
||||
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
padding: calc(var(--space-lg) - var(--space-xs));
|
||||
max-width: 500px;
|
||||
width: min(500px, 90vw);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal :deep(.modal-title),
|
||||
.modal :deep(h3) {
|
||||
margin: 0 0 var(--space-md);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
.modal :deep(form) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.modal :deep(.modal-form) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.modal :deep(.modal-form label) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal :deep(.modal-form input),
|
||||
.modal :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;
|
||||
}
|
||||
|
||||
.modal :deep(.modal-form input:focus),
|
||||
.modal :deep(.modal-form textarea:focus) {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px #c7d2fe;
|
||||
}
|
||||
|
||||
.modal :deep(.modal-actions) {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-md);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="name-edit-form">
|
||||
<label :for="resolvedInputId">{{ label }}
|
||||
<input
|
||||
:id="resolvedInputId"
|
||||
ref="inputRef"
|
||||
:type="inputType"
|
||||
:placeholder="placeholder"
|
||||
v-model="localValue"
|
||||
:disabled="busy"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div v-if="error" class="error small">{{ error }}</div>
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@click="handleCancel"
|
||||
:disabled="busy"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
:disabled="busy"
|
||||
>
|
||||
{{ submitText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
label: { type: String, default: 'Name' },
|
||||
placeholder: { type: String, default: '' },
|
||||
submitText: { type: String, default: 'Save' },
|
||||
cancelText: { type: String, default: 'Cancel' },
|
||||
busy: { type: Boolean, default: false },
|
||||
error: { type: String, default: '' },
|
||||
autoFocus: { type: Boolean, default: true },
|
||||
autoSelect: { type: Boolean, default: true },
|
||||
inputId: { type: String, default: null },
|
||||
inputType: { type: String, default: 'text' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'cancel'])
|
||||
const inputRef = ref(null)
|
||||
const generatedId = `name-edit-${Math.random().toString(36).slice(2, 10)}`
|
||||
|
||||
const localValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const resolvedInputId = computed(() => props.inputId || generatedId)
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.autoFocus) return
|
||||
nextTick(() => {
|
||||
if (props.autoSelect) {
|
||||
inputRef.value?.select()
|
||||
} else {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.name-edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-danger-text);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,43 +0,0 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="view active">
|
||||
<h1>🚫 Forbidden</h1>
|
||||
<div v-if="authStore.userInfo?.authenticated" class="user-header">
|
||||
<span class="user-emoji" aria-hidden="true">{{ userEmoji }}</span>
|
||||
<span class="user-name">{{ displayName }}</span>
|
||||
</div>
|
||||
<p>You lack the permissions required for this page.</p>
|
||||
<div class="actions">
|
||||
<button class="btn-secondary" @click="back">Back</button>
|
||||
<button class="btn-primary" @click="goAuth">Account</button>
|
||||
<button class="btn-danger" @click="logout">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const userEmoji = '👤' // Placeholder / could be extended later if backend provides one
|
||||
const displayName = authStore.userInfo?.user?.user_name || 'User'
|
||||
|
||||
function goAuth() {
|
||||
location.href = '/auth/'
|
||||
}
|
||||
function back() {
|
||||
if (history.length > 1) history.back()
|
||||
else authStore.currentView = 'login'
|
||||
}
|
||||
async function logout() {
|
||||
await authStore.logout()
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.user-header { display:flex; align-items:center; gap:.5rem; font-size:1.1rem; margin-bottom:.75rem; }
|
||||
.user-emoji { font-size:1.5rem; line-height:1; }
|
||||
.user-name { font-weight:600; }
|
||||
.actions { margin-top:1.5rem; display:flex; gap:.5rem; flex-wrap:nowrap; }
|
||||
.hint { font-size:.9rem; opacity:.85; }
|
||||
</style>
|
||||
@@ -1,8 +1,12 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="view active">
|
||||
<section class="view-root" data-view="profile">
|
||||
<header class="view-header">
|
||||
<h1>👋 Welcome!</h1>
|
||||
<Breadcrumbs :entries="[{ label: 'Auth', href: '/auth/' }, ...(isAdmin ? [{ label: 'Admin', href: '/auth/admin/' }] : [])]" />
|
||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
||||
<p class="view-lede">Manage your account details and passkeys.</p>
|
||||
</header>
|
||||
|
||||
<section class="section-block">
|
||||
<UserBasicInfo
|
||||
v-if="authStore.userInfo?.user"
|
||||
:name="authStore.userInfo.user.user_name"
|
||||
@@ -12,159 +16,188 @@
|
||||
:loading="authStore.isLoading"
|
||||
update-endpoint="/auth/api/user/display-name"
|
||||
@saved="authStore.loadUserInfo()"
|
||||
@edit-name="openNameDialog"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<h2>Your Passkeys</h2>
|
||||
<div class="credential-list">
|
||||
<div v-if="authStore.isLoading">
|
||||
<p>Loading credentials...</p>
|
||||
</div>
|
||||
<div v-else-if="authStore.userInfo?.credentials?.length === 0">
|
||||
<p>No passkeys found.</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="credential in authStore.userInfo?.credentials || []"
|
||||
:key="credential.credential_uuid"
|
||||
:class="['credential-item', { 'current-session': credential.is_current_session }]"
|
||||
>
|
||||
<div class="credential-header">
|
||||
<div class="credential-icon">
|
||||
<img
|
||||
v-if="getCredentialAuthIcon(credential)"
|
||||
:src="getCredentialAuthIcon(credential)"
|
||||
:alt="getCredentialAuthName(credential)"
|
||||
class="auth-icon"
|
||||
width="32"
|
||||
height="32"
|
||||
>
|
||||
<span v-else class="auth-emoji">🔑</span>
|
||||
</div>
|
||||
<div class="credential-info">
|
||||
<h4>{{ getCredentialAuthName(credential) }}</h4>
|
||||
</div>
|
||||
<div class="credential-dates">
|
||||
<span class="date-label">Created:</span>
|
||||
<span class="date-value">{{ formatDate(credential.created_at) }}</span>
|
||||
<span class="date-label">Last used:</span>
|
||||
<span class="date-value">{{ formatDate(credential.last_used) }}</span>
|
||||
</div>
|
||||
<div class="credential-actions">
|
||||
<button
|
||||
@click="deleteCredential(credential.credential_uuid)"
|
||||
class="btn-delete-credential"
|
||||
:disabled="credential.is_current_session"
|
||||
:title="credential.is_current_session ? 'Cannot delete current session credential' : ''"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="section-block">
|
||||
<div class="section-header">
|
||||
<h2>Your Passkeys</h2>
|
||||
<p class="section-description">Keep at least one trusted passkey so you can always sign in.</p>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<CredentialList
|
||||
:credentials="authStore.userInfo?.credentials || []"
|
||||
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
|
||||
:loading="authStore.isLoading"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
:hovered-session-credential-uuid="hoveredSession?.credential_uuid"
|
||||
allow-delete
|
||||
@delete="handleDelete"
|
||||
@credential-hover="hoveredCredentialUuid = $event"
|
||||
/>
|
||||
<div class="button-row">
|
||||
<button @click="addNewCredential" class="btn-primary">Add New Passkey</button>
|
||||
<button @click="showRegLink = true" class="btn-secondary">Add Another Device</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="button-group" style="display: flex; gap: 10px;">
|
||||
<button @click="addNewCredential" class="btn-primary">
|
||||
Add New Passkey
|
||||
</button>
|
||||
<button @click="authStore.currentView = 'device-link'" class="btn-primary">
|
||||
Add Another Device
|
||||
<SessionList
|
||||
:sessions="sessions"
|
||||
:terminating-sessions="terminatingSessions"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
@terminate="terminateSession"
|
||||
@session-hover="hoveredSession = $event"
|
||||
section-description="Review where you're signed in and end any sessions you no longer recognize."
|
||||
/>
|
||||
|
||||
<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">
|
||||
<div class="button-row logout-row" :class="{ single: !hasMultipleSessions }">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@click="goBack"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button v-if="!hasMultipleSessions" @click="logoutEverywhere" class="btn-danger logout-button" :disabled="authStore.isLoading">Logout</button>
|
||||
<template v-else>
|
||||
<button @click="logout" class="btn-danger logout-button" :disabled="authStore.isLoading">Logout</button>
|
||||
<button @click="logoutEverywhere" class="btn-danger logout-button" :disabled="authStore.isLoading">All</button>
|
||||
</template>
|
||||
</div>
|
||||
<button @click="logout" class="btn-danger" style="width: 100%;">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
</section>
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegLink"
|
||||
:endpoint="'/auth/api/user/create-link'"
|
||||
:auto-copy="false"
|
||||
:prefix-copy-with-user-name="false"
|
||||
@close="showRegLink = false"
|
||||
@copied="showRegLink = false; authStore.showMessage('Link copied to clipboard!', 'success', 2500)"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
import passkey from '@/utils/passkey'
|
||||
import CredentialList from '@/components/CredentialList.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 { 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'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const updateInterval = ref(null)
|
||||
const showNameDialog = ref(false)
|
||||
const showRegLink = ref(false)
|
||||
const newName = ref('')
|
||||
const saving = ref(false)
|
||||
const hoveredCredentialUuid = ref(null)
|
||||
const hoveredSession = ref(null)
|
||||
|
||||
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
|
||||
|
||||
onMounted(() => {
|
||||
updateInterval.value = setInterval(() => {
|
||||
// Trigger Vue reactivity to update formatDate fields
|
||||
if (authStore.userInfo) {
|
||||
authStore.userInfo = { ...authStore.userInfo }
|
||||
}
|
||||
}, 60000) // Update every minute
|
||||
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (updateInterval.value) {
|
||||
clearInterval(updateInterval.value)
|
||||
}
|
||||
})
|
||||
|
||||
const getCredentialAuthName = (credential) => {
|
||||
const authInfo = authStore.userInfo?.aaguid_info?.[credential.aaguid]
|
||||
return authInfo ? authInfo.name : 'Unknown Authenticator'
|
||||
}
|
||||
|
||||
const getCredentialAuthIcon = (credential) => {
|
||||
const authInfo = authStore.userInfo?.aaguid_info?.[credential.aaguid]
|
||||
if (!authInfo) return null
|
||||
|
||||
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
|
||||
return authInfo[iconKey] || null
|
||||
}
|
||||
onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) })
|
||||
|
||||
const addNewCredential = async () => {
|
||||
try {
|
||||
authStore.isLoading = true
|
||||
authStore.showMessage('Adding new passkey...', 'info')
|
||||
await passkey.register()
|
||||
await passkey.register(null, null, () => {
|
||||
authStore.showMessage('Adding new passkey...', 'info')
|
||||
})
|
||||
await authStore.loadUserInfo()
|
||||
authStore.showMessage('New passkey added successfully!', 'success', 3000)
|
||||
} catch (error) {
|
||||
console.error('Failed to add new passkey:', error)
|
||||
authStore.showMessage(error.message, 'error')
|
||||
} finally {
|
||||
authStore.isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCredential = async (credentialId) => {
|
||||
const handleDelete = async (credential) => {
|
||||
const credentialId = credential?.credential_uuid
|
||||
if (!credentialId) return
|
||||
if (!confirm('Are you sure you want to delete this passkey?')) return
|
||||
try {
|
||||
await authStore.deleteCredential(credentialId)
|
||||
authStore.showMessage('Passkey deleted successfully!', 'success', 3000)
|
||||
} catch (error) {
|
||||
authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error')
|
||||
} catch (error) { authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error') }
|
||||
}
|
||||
|
||||
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
|
||||
const sessions = computed(() => authStore.userInfo?.sessions || [])
|
||||
const currentSessionHost = computed(() => {
|
||||
const currentSession = 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) }
|
||||
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
|
||||
finally {
|
||||
const next = { ...terminatingSessions.value }
|
||||
delete next[sessionId]
|
||||
terminatingSessions.value = next
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
await authStore.logout()
|
||||
}
|
||||
|
||||
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
|
||||
const logout = async () => { await authStore.logout() }
|
||||
const openNameDialog = () => { newName.value = authStore.userInfo?.user?.user_name || ''; showNameDialog.value = true }
|
||||
const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_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 saveName = async () => {
|
||||
const name = newName.value.trim()
|
||||
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
|
||||
try {
|
||||
saving.value = true
|
||||
await apiJson('/auth/api/user/display-name', { method: 'PUT', 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 }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Removed inline user info styles; now provided by UserBasicInfo component */
|
||||
.admin-link {
|
||||
font-size: 0.6em;
|
||||
margin-left: 0.75rem;
|
||||
text-decoration: none;
|
||||
background: var(--color-background-soft, #eee);
|
||||
padding: 0.2em 0.6em;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border, #ccc);
|
||||
vertical-align: middle;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.admin-link:hover {
|
||||
background: var(--color-background-mute, #ddd);
|
||||
}
|
||||
.view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; }
|
||||
.section-header { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.section-description { margin: 0; color: var(--color-text-muted); }
|
||||
.empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; }
|
||||
.logout-button { align-self: flex-start; }
|
||||
.logout-row { gap: 1rem; }
|
||||
.logout-row.single { justify-content: flex-start; }
|
||||
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
|
||||
@media (max-width: 720px) { .logout-button { width: 100%; } }
|
||||
</style>
|
||||
|
||||
@@ -1,29 +1,54 @@
|
||||
<template>
|
||||
<div class="dialog-overlay" @keydown.esc.prevent="$emit('close')">
|
||||
<div v-if="!inline && url" class="dialog-overlay" @keydown.esc.prevent="$emit('close')">
|
||||
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
|
||||
<h2 id="regTitle" style="margin:0; font-size:1.25rem;">📱 Device Registration Link</h2>
|
||||
<div class="reg-header-row">
|
||||
<h2 id="regTitle" class="reg-title">
|
||||
📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span>
|
||||
</h2>
|
||||
<button class="icon-btn" @click="$emit('close')" aria-label="Close">❌</button>
|
||||
</div>
|
||||
<div class="device-link-section">
|
||||
<div class="qr-container">
|
||||
<a v-if="url" :href="url" @click.prevent="copy" class="qr-link">
|
||||
<a :href="url" @click.prevent="copy" class="qr-link">
|
||||
<canvas ref="qrCanvas" class="qr-code"></canvas>
|
||||
<p>{{ displayUrl }}</p>
|
||||
</a>
|
||||
<div v-else>
|
||||
<em>Generating link...</em>
|
||||
</div>
|
||||
<p>
|
||||
<strong>Scan and visit the URL on another device.</strong><br>
|
||||
<small>⚠️ Expires in 24 hours and one-time use.</small>
|
||||
<p class="reg-help">
|
||||
<span v-if="userName">The user should open this link on the device where they want to register.</span>
|
||||
<span v-else>Open or scan this link on the device you wish to register to your account.</span>
|
||||
<br><small>{{ expirationMessage }}</small>
|
||||
</p>
|
||||
<div v-if="expires" style="font-size:12px; margin-top:6px;">Expires: {{ new Date(expires).toLocaleString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:flex-end; gap:.5rem; margin-top:10px;">
|
||||
<div class="reg-actions">
|
||||
<button class="btn-secondary" @click="$emit('close')">Close</button>
|
||||
<button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button>
|
||||
<button class="btn-primary" @click="copy">Copy Link</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="inline && url" class="registration-inline-wrapper">
|
||||
<div class="registration-inline-block section-block">
|
||||
<div class="section-header">
|
||||
<h2 class="inline-heading">📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span></h2>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div class="device-link-section">
|
||||
<div class="qr-container">
|
||||
<a :href="url" @click.prevent="copy" class="qr-link">
|
||||
<canvas ref="qrCanvas" class="qr-code"></canvas>
|
||||
<p>{{ displayUrl }}</p>
|
||||
</a>
|
||||
<p class="reg-help">
|
||||
<span v-if="userName">The user should open this link on the device where they want to register.</span>
|
||||
<span v-else>Open this link on the device you wish to connect with.</span>
|
||||
<br><small>{{ expirationMessage }}</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row" style="margin-top:1rem;">
|
||||
<button class="btn-primary" @click="copy">Copy Link</button>
|
||||
<button v-if="showCloseInInline" class="btn-secondary" @click="$emit('close')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,10 +57,19 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, computed, nextTick } from 'vue'
|
||||
import QRCode from 'qrcode/lib/browser'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiJson, getUserFriendlyErrorMessage, shouldShowErrorToast } from '@/utils/api'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: { type: String, required: true }, // POST endpoint returning {url, expires}
|
||||
autoCopy: { type: Boolean, default: true }
|
||||
endpoint: { type: String, required: true },
|
||||
autoCopy: { type: Boolean, default: true },
|
||||
userName: { type: String, default: null },
|
||||
inline: { type: Boolean, default: false },
|
||||
showCloseInInline: { type: Boolean, default: false },
|
||||
prefixCopyWithUserName: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close','generated','copied'])
|
||||
@@ -46,11 +80,14 @@ const qrCanvas = ref(null)
|
||||
|
||||
const displayUrl = computed(() => url.value ? url.value.replace(/^[^:]+:\/\//,'') : '')
|
||||
|
||||
const expirationMessage = computed(() => {
|
||||
const timeStr = formatDate(expires.value)
|
||||
return `⚠️ Expires ${timeStr.startsWith('In ') ? timeStr.substring(3) : timeStr} and can only be used once.`
|
||||
})
|
||||
|
||||
async function fetchLink() {
|
||||
try {
|
||||
const res = await fetch(props.endpoint, { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
const data = await apiJson(props.endpoint, { method: 'POST' })
|
||||
url.value = data.url
|
||||
expires.value = data.expires
|
||||
emit('generated', { url: data.url, expires: data.expires })
|
||||
@@ -58,9 +95,12 @@ async function fetchLink() {
|
||||
drawQR()
|
||||
if (props.autoCopy) copy()
|
||||
} catch (e) {
|
||||
url.value = null
|
||||
expires.value = null
|
||||
console.error('Failed to create link', e)
|
||||
if (shouldShowErrorToast(e)) {
|
||||
authStore.showMessage(getUserFriendlyErrorMessage(e), 'error', 4000)
|
||||
}
|
||||
// Close the dialog on any error (auth cancelled, network error, etc.)
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,15 +113,35 @@ async function drawQR() {
|
||||
|
||||
async function copy() {
|
||||
if (!url.value) return
|
||||
try { await navigator.clipboard.writeText(url.value); emit('copied', url.value); emit('close') } catch (_) { /* ignore */ }
|
||||
let text = url.value
|
||||
if (props.prefixCopyWithUserName && props.userName) {
|
||||
text = `${props.userName} ${text}`
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
emit('copied', text)
|
||||
if (!props.inline) emit('close')
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchLink)
|
||||
watch(url, () => drawQR(), { flush: 'post' })
|
||||
|
||||
</script>
|
||||
<style scoped>
|
||||
.icon-btn { background:none; border:none; cursor:pointer; font-size:1rem; opacity:.6; }
|
||||
.icon-btn:hover { opacity:1; }
|
||||
/* Minimal extra styling; main look comes from global styles */
|
||||
.qr-link { text-decoration:none; color:inherit; }
|
||||
.reg-header-row { display:flex; justify-content:space-between; align-items:center; gap:.75rem; margin-bottom:.75rem; }
|
||||
.reg-title { margin:0; font-size:1.25rem; font-weight:600; }
|
||||
.device-dialog { background: var(--color-surface); padding: 1.25rem 1.25rem 1rem; border-radius: var(--radius-md); max-width:480px; width:100%; box-shadow:0 6px 28px rgba(0,0,0,.25); }
|
||||
.qr-container { display:flex; flex-direction:column; align-items:center; gap:.5rem; }
|
||||
.qr-code { display:block; }
|
||||
.reg-help { margin-top:.5rem; margin-bottom:.75rem; font-size:.85rem; line-height:1.25rem; text-align:center; }
|
||||
.reg-actions { display:flex; justify-content:flex-end; gap:.5rem; margin-top:.25rem; }
|
||||
.registration-inline-block .qr-container { align-items:flex-start; }
|
||||
.registration-inline-block .reg-help { text-align:left; }
|
||||
</style>
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="view active">
|
||||
<h1>🔑 Add New Credential</h1>
|
||||
<label class="name-edit">
|
||||
<span>👤 Name:</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="user_name"
|
||||
:placeholder="authStore.userInfo?.user?.user_name || 'Your name'"
|
||||
:disabled="authStore.isLoading"
|
||||
maxlength="64"
|
||||
@keyup.enter="register"
|
||||
/>
|
||||
</label>
|
||||
<p>Proceed to complete {{authStore.userInfo?.session_type}}:</p>
|
||||
<button
|
||||
class="btn-primary"
|
||||
:disabled="authStore.isLoading"
|
||||
@click="register"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Registering...' : 'Register Passkey' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import passkey from '@/utils/passkey'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const user_name = ref('') // intentionally blank; original shown via placeholder
|
||||
|
||||
async function register() {
|
||||
authStore.isLoading = true
|
||||
authStore.showMessage('Starting registration...', 'info')
|
||||
|
||||
try {
|
||||
const result = await passkey.register(authStore.resetToken, user_name.value)
|
||||
console.log("Result", result)
|
||||
await authStore.setSessionCookie(result.session_token)
|
||||
// resetToken cleared by setSessionCookie; ensure again
|
||||
authStore.resetToken = null
|
||||
authStore.showMessage('Passkey registered successfully!', 'success', 2000)
|
||||
await authStore.loadUserInfo()
|
||||
authStore.selectView()
|
||||
} catch (error) {
|
||||
authStore.showMessage(`Registration failed: ${error.message}`, 'error')
|
||||
} finally {
|
||||
authStore.isLoading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<div v-if="status.show" class="global-status" style="display: block;">
|
||||
<div :class="['status', status.type]">
|
||||
{{ status.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="view-root">
|
||||
<div v-if="!initializing" class="surface surface--tight">
|
||||
<header class="view-header center">
|
||||
<h1>{{ headingTitle }}</h1>
|
||||
<p v-if="isAuthenticated" class="user-line">👤 {{ userDisplayName }}</p>
|
||||
<p class="view-lede">{{ headerMessage }}</p>
|
||||
</header>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="section-body center">
|
||||
<div class="button-row center">
|
||||
<slot name="actions"
|
||||
:loading="loading"
|
||||
:can-authenticate="canAuthenticate"
|
||||
:is-authenticated="isAuthenticated"
|
||||
:authenticate="authenticateUser"
|
||||
:logout="logoutUser"
|
||||
:mode="mode">
|
||||
<!-- Default actions -->
|
||||
<button class="btn-secondary" :disabled="loading" @click="$emit('back')">Back</button>
|
||||
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser">
|
||||
{{ loading ? (mode === 'reauth' ? 'Verifying…' : 'Signing in…') : (mode === 'reauth' ? 'Verify' : 'Login') }}
|
||||
</button>
|
||||
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-danger" :disabled="loading" @click="logoutUser">Logout</button>
|
||||
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-primary" :disabled="loading" @click="openProfile">Profile</button>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import passkey from '@/utils/passkey'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'login',
|
||||
validator: (value) => ['login', 'reauth', 'forbidden'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['authenticated', 'forbidden', 'logout', 'back', 'home', 'auth-error'])
|
||||
|
||||
const status = reactive({ show: false, message: '', type: 'info' })
|
||||
const initializing = ref(true)
|
||||
const loading = ref(false)
|
||||
const settings = ref(null)
|
||||
const userInfo = ref(null)
|
||||
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
|
||||
let statusTimer = null
|
||||
|
||||
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
|
||||
|
||||
const canAuthenticate = computed(() => {
|
||||
if (initializing.value) return false
|
||||
// In reauth mode, allow authentication even if already authenticated
|
||||
if (props.mode === 'reauth') return true
|
||||
// In forbidden view (authenticated but lacking permissions), don't allow authentication
|
||||
if (currentView.value === 'forbidden') return false
|
||||
// In login view or initial state, allow authentication
|
||||
return true
|
||||
})
|
||||
|
||||
const headingTitle = computed(() => {
|
||||
if (props.mode === 'reauth') {
|
||||
return `🔐 Additional Authentication`
|
||||
}
|
||||
if (currentView.value === 'forbidden') return '🚫 Forbidden'
|
||||
return `🔐 ${settings.value?.rp_name || location.origin}`
|
||||
})
|
||||
|
||||
const headerMessage = computed(() => {
|
||||
if (props.mode === 'reauth') {
|
||||
return 'Please verify your identity to continue with this action.'
|
||||
}
|
||||
if (currentView.value === 'forbidden') {
|
||||
return 'You lack the required permissions.'
|
||||
}
|
||||
return 'Please sign in with your passkey.'
|
||||
})
|
||||
|
||||
const userDisplayName = computed(() => userInfo.value?.user?.user_name || 'User')
|
||||
|
||||
function showMessage(message, type = 'info', duration = 3000) {
|
||||
status.show = true
|
||||
status.message = message
|
||||
status.type = type
|
||||
if (statusTimer) clearTimeout(statusTimer)
|
||||
if (duration > 0) statusTimer = setTimeout(() => { status.show = false }, duration)
|
||||
}
|
||||
|
||||
async function fetchSettings() {
|
||||
try {
|
||||
const data = await getSettings()
|
||||
settings.value = data
|
||||
if (data?.rp_name) {
|
||||
const titleSuffix = props.mode === 'reauth'
|
||||
? 'Verify Identity'
|
||||
: (isAuthenticated.value ? 'Forbidden' : 'Sign In')
|
||||
document.title = `${data.rp_name} · ${titleSuffix}`
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Unable to load settings', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserInfo() {
|
||||
try {
|
||||
userInfo.value = await fetchJson('/auth/api/user-info', { method: 'POST' })
|
||||
// Determine view based on authentication status
|
||||
if (isAuthenticated.value && props.mode !== 'reauth') {
|
||||
currentView.value = 'forbidden'
|
||||
emit('forbidden', userInfo.value)
|
||||
} else {
|
||||
currentView.value = 'login'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user info', error)
|
||||
// For 401/403 just go to login, for other errors show message
|
||||
if (error.status !== 401 && error.status !== 403) {
|
||||
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
|
||||
}
|
||||
userInfo.value = null
|
||||
currentView.value = 'login'
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateUser() {
|
||||
if (!canAuthenticate.value || loading.value) return
|
||||
loading.value = true
|
||||
showMessage('Starting authentication…', 'info')
|
||||
let result
|
||||
try { result = await passkey.authenticate() } catch (error) {
|
||||
loading.value = false
|
||||
const message = error?.message || 'Passkey authentication cancelled'
|
||||
const cancelled = message === 'Passkey authentication cancelled'
|
||||
showMessage(message, cancelled ? 'info' : 'error', 4000)
|
||||
emit('auth-error', { message, cancelled })
|
||||
return
|
||||
}
|
||||
try { await setSessionCookie(result) } catch (error) {
|
||||
loading.value = false
|
||||
const message = error?.message || 'Failed to establish session'
|
||||
showMessage(message, 'error', 4000)
|
||||
emit('auth-error', { message, cancelled: false })
|
||||
return
|
||||
}
|
||||
loading.value = false
|
||||
emit('authenticated', result)
|
||||
}
|
||||
|
||||
async function logoutUser() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await fetchJson('/auth/api/logout', { method: 'POST' })
|
||||
userInfo.value = null
|
||||
// Switch to login view after logout
|
||||
currentView.value = 'login'
|
||||
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
|
||||
} catch (error) {
|
||||
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
|
||||
}
|
||||
finally { loading.value = false }
|
||||
emit('logout')
|
||||
}
|
||||
|
||||
function openProfile() {
|
||||
// Open profile in a new window with a specific name to reuse the same tab
|
||||
const profileWindow = window.open('/auth/', 'passkey_auth_profile')
|
||||
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')
|
||||
}
|
||||
return await fetchJson('/auth/api/set-session', {
|
||||
method: 'POST', headers: { Authorization: `Bearer ${result.session_token}` }
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchSettings()
|
||||
await fetchUserInfo()
|
||||
initializing.value = false
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
showMessage,
|
||||
isAuthenticated,
|
||||
userInfo
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.button-row.center { display: flex; justify-content: center; gap: 0.75rem; }
|
||||
.user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); }
|
||||
/* Vertically center the restricted "dialog" surface in the viewport */
|
||||
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||
.surface.surface--tight {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<section class="section-block" 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 :class="['session-list']">
|
||||
<template v-if="Array.isArray(sessions) && sessions.length">
|
||||
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group">
|
||||
<h3 :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
|
||||
<template v-if="host"><a :href="hostUrl(host)">🌐 {{ host }}</a></template>
|
||||
<template v-else>🌐 Unbound host</template>
|
||||
</h3>
|
||||
<div class="session-group-sessions">
|
||||
<div
|
||||
v-for="session in group.sessions"
|
||||
:key="session.id"
|
||||
:class="['session-item', {
|
||||
'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
|
||||
'is-hovered': hoveredSession?.id === session.id,
|
||||
'is-linked-credential': hoveredCredentialUuid === session.credential_uuid
|
||||
}]"
|
||||
tabindex="0"
|
||||
@focusin="handleSessionFocus(session)"
|
||||
@focusout="handleSessionBlur($event)"
|
||||
>
|
||||
<div class="item-top">
|
||||
<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="hoveredCredentialUuid === session.credential_uuid" class="badge badge-current">Linked</span>
|
||||
<span v-else-if="!hoveredCredentialUuid && isSameNetwork(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'"
|
||||
>🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<div class="session-dates">
|
||||
<span class="date-label">{{ formatDate(session.last_renewed) }}</span>
|
||||
<span class="date-value">{{ session.ip }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-state"><p>{{ emptyMessage }}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
sessions: { type: Array, 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 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['terminate', 'sessionHover'])
|
||||
|
||||
const hoveredIp = ref(null)
|
||||
const hoveredSession = ref(null)
|
||||
|
||||
const handleSessionFocus = (session) => {
|
||||
hoveredSession.value = session
|
||||
hoveredIp.value = session.ip || null
|
||||
emit('sessionHover', session)
|
||||
}
|
||||
|
||||
const handleSessionBlur = (event) => {
|
||||
// Only clear if focus moved outside this element
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
hoveredSession.value = null
|
||||
hoveredIp.value = null
|
||||
emit('sessionHover', null)
|
||||
}
|
||||
}
|
||||
|
||||
const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId]
|
||||
|
||||
const hostUrl = (host) => {
|
||||
// Assume http if there's a port number, https otherwise
|
||||
const protocol = host.includes(':') ? 'http' : 'https'
|
||||
return `${protocol}://${host}`
|
||||
}
|
||||
|
||||
// Extract /64 prefix for IPv6, or return full IP for IPv4
|
||||
const getNetworkPrefix = ip => {
|
||||
if (!ip) return null
|
||||
|
||||
// IPv4?
|
||||
if (!ip.includes(':')) return ip
|
||||
|
||||
// Normalize IPv6 using URL
|
||||
// Wrap in brackets so URL accepts it
|
||||
const norm = new URL(`http://[${ip}]/`).hostname
|
||||
|
||||
// norm is now fully expanded, e.g. "2001:0db8:0000:0000:0000:0000:0000:0001"
|
||||
const parts = norm.split(':')
|
||||
return parts.slice(0, 4).join(':')
|
||||
}
|
||||
|
||||
const currentNetworkPrefix = computed(() => {
|
||||
// Use hovered IP if available, otherwise fall back to current session
|
||||
if (hoveredIp.value) return getNetworkPrefix(hoveredIp.value)
|
||||
const current = props.sessions.find(s => s.is_current)
|
||||
return current ? getNetworkPrefix(current.ip) : null
|
||||
})
|
||||
|
||||
const isSameNetwork = (ip) => {
|
||||
if (!currentNetworkPrefix.value || !ip) return false
|
||||
return getNetworkPrefix(ip) === currentNetworkPrefix.value
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
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
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.session-meta-info {
|
||||
grid-column: span 2;
|
||||
}
|
||||
[data-component="session-list-section"] .session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5em;
|
||||
}
|
||||
.session-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.session-group-host {
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
.session-group-host a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.session-group-host a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.session-group-host.is-current-site {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.session-group-sessions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(var(--card-width), 1fr));
|
||||
gap: 0.5em;
|
||||
align-items: start;
|
||||
}
|
||||
.session-group-sessions .session-item {
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.session-group-sessions .session-item .item-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.session-group-sessions .session-item .item-details {
|
||||
margin-left: 0;
|
||||
}
|
||||
.session-group-sessions .session-item .session-dates {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
</style>
|
||||
@@ -2,21 +2,9 @@
|
||||
<div v-if="userLoaded" class="user-info">
|
||||
<h3 class="user-name-heading">
|
||||
<span class="icon">👤</span>
|
||||
<span v-if="!editingName" class="user-name-row">
|
||||
<span class="user-name-row">
|
||||
<span class="display-name" :title="name">{{ name }}</span>
|
||||
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="startEdit" title="Edit name">✏️</button>
|
||||
</span>
|
||||
<span v-else class="user-name-row editing">
|
||||
<input
|
||||
v-model="newName"
|
||||
class="name-input"
|
||||
:placeholder="name"
|
||||
:disabled="busy || loading"
|
||||
maxlength="64"
|
||||
@keyup.enter="saveName"
|
||||
/>
|
||||
<button class="mini-btn" @click="saveName" :disabled="busy || loading" title="Save name">💾</button>
|
||||
<button class="mini-btn" @click="cancelEdit" :disabled="busy || loading" title="Cancel">✖</button>
|
||||
<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">
|
||||
@@ -49,53 +37,29 @@ const props = defineProps({
|
||||
roleName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['saved'])
|
||||
const emit = defineEmits(['saved', 'editName'])
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const editingName = ref(false)
|
||||
const newName = ref('')
|
||||
const busy = ref(false)
|
||||
const userLoaded = computed(() => !!props.name)
|
||||
|
||||
function startEdit() { editingName.value = true; newName.value = '' }
|
||||
function cancelEdit() { editingName.value = false }
|
||||
async function saveName() {
|
||||
if (!props.updateEndpoint) { editingName.value = false; return }
|
||||
try {
|
||||
busy.value = true
|
||||
authStore.isLoading = true
|
||||
const bodyName = newName.value.trim()
|
||||
if (!bodyName) { cancelEdit(); return }
|
||||
const res = await fetch(props.updateEndpoint, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: bodyName }) })
|
||||
let data = {}
|
||||
try { data = await res.json() } catch (_) {}
|
||||
if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed')
|
||||
editingName.value = false
|
||||
authStore.showMessage('Name updated', 'success', 1500)
|
||||
emit('saved')
|
||||
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
|
||||
finally { busy.value = false; authStore.isLoading = false }
|
||||
}
|
||||
watch(() => props.name, () => { if (!props.name) editingName.value = false })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-info { display: grid; grid-template-columns: auto 1fr; gap: 10px; }
|
||||
.user-info h3 { grid-column: span 2; }
|
||||
.org-role-sub { grid-column: span 2; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
|
||||
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; }
|
||||
.role-line { font-size:.6rem; color:#555; line-height:1.1; }
|
||||
.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; }
|
||||
.user-info span { text-align: left; }
|
||||
.user-name-heading { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
|
||||
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; }
|
||||
.user-name-row.editing { flex: 1 1 auto; }
|
||||
.icon { flex: 0 0 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 #a9c5d6; border-radius: 6px; }
|
||||
.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: 2px solid #667eea55; border-color: #667eea; }
|
||||
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; background: #eef5fa; border: 1px solid #b7d2e3; border-radius: 6px; cursor: pointer; transition: background 0.2s, transform 0.15s; }
|
||||
.mini-btn:hover:not(:disabled) { background: #dcecf6; }
|
||||
.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; background: var(--color-surface-muted); border: 1px solid var(--color-border-strong); border-radius: 6px; cursor: pointer; transition: background 0.2s, transform 0.15s, color 0.2s ease; color: var(--color-text); }
|
||||
.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; }
|
||||
@media (max-width: 480px) { .user-name-heading { flex-direction: column; align-items: flex-start; } .user-name-row.editing { width: 100%; } .display-name { max-width: 100%; } }
|
||||
|
||||
+70
-67
@@ -1,14 +1,16 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { register, authenticate } from '@/utils/passkey'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
import { apiJson } from '@/utils/api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
// Auth State
|
||||
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated}
|
||||
settings: null, // Server provided settings (/auth/settings)
|
||||
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
|
||||
isLoading: false,
|
||||
resetToken: null, // transient reset token
|
||||
restrictedMode: false, // Anywhere other than /auth/: restrict to login or permission denied
|
||||
|
||||
// Settings
|
||||
settings: null,
|
||||
|
||||
// UI State
|
||||
currentView: 'login',
|
||||
@@ -18,7 +20,12 @@ export const useAuthStore = defineStore('auth', {
|
||||
show: false
|
||||
},
|
||||
}),
|
||||
getters: {
|
||||
},
|
||||
actions: {
|
||||
setLoading(flag) {
|
||||
this.isLoading = !!flag
|
||||
},
|
||||
showMessage(message, type = 'info', duration = 3000) {
|
||||
this.status = {
|
||||
message,
|
||||
@@ -31,26 +38,23 @@ export const useAuthStore = defineStore('auth', {
|
||||
}, duration)
|
||||
}
|
||||
},
|
||||
async setSessionCookie(sessionToken) {
|
||||
const response = await fetch('/auth/api/set-session', {
|
||||
method: 'POST',
|
||||
headers: {'Authorization': `Bearer ${sessionToken}`},
|
||||
})
|
||||
const result = await response.json()
|
||||
if (result.detail) {
|
||||
throw new Error(result.detail)
|
||||
async setSessionCookie(result) {
|
||||
if (!result?.session_token) {
|
||||
console.error('setSessionCookie called with missing session_token:', result)
|
||||
throw new Error('Authentication response missing session_token')
|
||||
}
|
||||
// On successful session establishment, discard any reset token to avoid
|
||||
// sending stale Authorization headers on subsequent API calls.
|
||||
this.resetToken = null
|
||||
return result
|
||||
return await apiJson('/auth/api/set-session', {
|
||||
method: 'POST',
|
||||
headers: {'Authorization': `Bearer ${result.session_token}`},
|
||||
})
|
||||
},
|
||||
async register() {
|
||||
this.isLoading = true
|
||||
try {
|
||||
const result = await register()
|
||||
await this.setSessionCookie(result.session_token)
|
||||
await this.setSessionCookie(result)
|
||||
await this.loadUserInfo()
|
||||
this.selectView()
|
||||
return result
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
@@ -61,8 +65,9 @@ export const useAuthStore = defineStore('auth', {
|
||||
try {
|
||||
const result = await authenticate()
|
||||
|
||||
await this.setSessionCookie(result.session_token)
|
||||
await this.setSessionCookie(result)
|
||||
await this.loadUserInfo()
|
||||
this.selectView()
|
||||
|
||||
return result
|
||||
} finally {
|
||||
@@ -70,71 +75,69 @@ export const useAuthStore = defineStore('auth', {
|
||||
}
|
||||
},
|
||||
selectView() {
|
||||
if (this.restrictedMode) {
|
||||
// In restricted mode only allow login or show permission denied if already authenticated
|
||||
if (!this.userInfo) this.currentView = 'login'
|
||||
else if (this.userInfo.authenticated) this.currentView = 'permission-denied'
|
||||
else this.currentView = 'login' // do not expose reset/registration flows outside /auth/
|
||||
return
|
||||
}
|
||||
if (!this.userInfo) this.currentView = 'login'
|
||||
else if (this.userInfo.authenticated) this.currentView = 'profile'
|
||||
else this.currentView = 'reset'
|
||||
},
|
||||
setRestrictedMode(flag) {
|
||||
this.restrictedMode = !!flag
|
||||
},
|
||||
async loadUserInfo() {
|
||||
const headers = {}
|
||||
// Reset tokens are only passed via query param now, not Authorization header
|
||||
const url = this.resetToken ? `/auth/api/user-info?reset=${encodeURIComponent(this.resetToken)}` : '/auth/api/user-info'
|
||||
const response = await fetch(url, { method: 'POST', headers })
|
||||
let result = null
|
||||
try {
|
||||
result = await response.json()
|
||||
} catch (_) {
|
||||
// ignore JSON parse errors (unlikely)
|
||||
}
|
||||
if (response.status === 401 && result?.detail) {
|
||||
this.showMessage(result.detail, 'error', 5000)
|
||||
throw new Error(result.detail)
|
||||
}
|
||||
if (result?.detail) {
|
||||
// Other error style
|
||||
this.showMessage(result.detail, 'error', 5000)
|
||||
throw new Error(result.detail)
|
||||
}
|
||||
this.userInfo = result
|
||||
console.log('User info loaded:', result)
|
||||
else this.currentView = 'profile'
|
||||
},
|
||||
async loadSettings() {
|
||||
this.settings = await getSettings()
|
||||
},
|
||||
async loadUserInfo() {
|
||||
try {
|
||||
const res = await fetch('/auth/api/settings')
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
this.settings = data
|
||||
if (data?.rp_name) {
|
||||
document.title = data.rp_name
|
||||
this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
|
||||
console.log('User info loaded:', this.userInfo)
|
||||
} catch (error) {
|
||||
// Suppress toast for 401/403 errors - the auth iframe will handle these
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
console.log('Authentication required:', error.message)
|
||||
} else {
|
||||
this.showMessage(error.message || 'Failed to load user info', 'error', 5000)
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
throw error
|
||||
}
|
||||
},
|
||||
async deleteCredential(uuid) {
|
||||
const response = await fetch(`/auth/api/credential/${uuid}`, {method: 'Delete'})
|
||||
const result = await response.json()
|
||||
if (result.detail) throw new Error(`Server: ${result.detail}`)
|
||||
|
||||
await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
|
||||
await this.loadUserInfo()
|
||||
},
|
||||
async terminateSession(sessionId) {
|
||||
try {
|
||||
const payload = await apiJson(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' })
|
||||
if (payload?.current_session_terminated) {
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
return
|
||||
}
|
||||
await this.loadUserInfo()
|
||||
this.showMessage('Session terminated', 'success', 2500)
|
||||
} catch (error) {
|
||||
console.error('Terminate session error:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
await fetch('/auth/api/logout', {method: 'POST'})
|
||||
await apiJson('/auth/api/logout', {method: 'POST'})
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
this.showMessage(error.message, 'error')
|
||||
// Suppress toast for 401/403 errors - the auth iframe will handle these
|
||||
if (error.status !== 401 && error.status !== 403) {
|
||||
this.showMessage(error.message, 'error')
|
||||
}
|
||||
}
|
||||
},
|
||||
async logoutEverywhere() {
|
||||
try {
|
||||
await apiJson('/auth/api/user/logout-all', {method: 'POST'})
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
console.error('Logout-all error:', error)
|
||||
// Suppress toast for 401/403 errors - the auth iframe will handle these
|
||||
if (error.status !== 401 && error.status !== 403) {
|
||||
this.showMessage(error.message, 'error')
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* 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
|
||||
|
||||
// Cache for auth iframe URL by mode
|
||||
const authIframeUrlCache = {}
|
||||
|
||||
/**
|
||||
* Get the auth iframe URL for a given mode.
|
||||
* Fetches from /auth/api/forward which returns URL in the auth.iframe field.
|
||||
* Results are cached per mode.
|
||||
* @param {string} mode - The auth mode ('login', 'reauth', 'forbidden')
|
||||
* @returns {Promise<string>} - The URL for the iframe
|
||||
*/
|
||||
export async function getAuthIframeUrl(mode = 'login') {
|
||||
if (authIframeUrlCache[mode]) {
|
||||
return authIframeUrlCache[mode]
|
||||
}
|
||||
|
||||
// Fetch from forward endpoint - it returns URL in auth.iframe on 401/403
|
||||
const response = await fetch('/auth/api/forward', { credentials: 'include' })
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const data = await response.json()
|
||||
if (data.auth?.iframe) {
|
||||
// The iframe field now contains a URL with hash fragment
|
||||
// If mode differs, update the hash param
|
||||
let url = data.auth.iframe
|
||||
if (mode !== data.auth.mode) {
|
||||
url = url.replace(/mode=[^&]*/, `mode=${mode}`)
|
||||
}
|
||||
authIframeUrlCache[mode] = url
|
||||
return url
|
||||
}
|
||||
}
|
||||
throw new Error('Unable to fetch auth iframe URL')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an auth iframe is already open (from any source).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAuthIframeOpen() {
|
||||
return !!document.getElementById('auth-iframe')
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the authentication iframe and return a promise that resolves on success.
|
||||
* If an auth iframe is already open (from any source), hooks into its completion.
|
||||
* @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()
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthMessage(event) {
|
||||
const data = event.data
|
||||
if (!data?.type) return
|
||||
|
||||
switch (data.type) {
|
||||
case 'auth-success':
|
||||
hideAuthIframe()
|
||||
if (authResolve) {
|
||||
authResolve()
|
||||
authPromise = null
|
||||
authResolve = null
|
||||
authReject = null
|
||||
}
|
||||
break
|
||||
|
||||
case 'auth-back':
|
||||
case 'auth-close-request':
|
||||
hideAuthIframe()
|
||||
if (authReject) {
|
||||
authReject(new AuthCancelledError())
|
||||
authPromise = null
|
||||
authResolve = null
|
||||
authReject = null
|
||||
}
|
||||
break
|
||||
|
||||
case 'auth-error':
|
||||
// Keep iframe open for retry, but if cancelled, treat as back
|
||||
if (data.cancelled && authReject) {
|
||||
hideAuthIframe()
|
||||
authReject(new AuthCancelledError())
|
||||
authPromise = null
|
||||
authResolve = null
|
||||
authReject = null
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Install global message listener
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('message', handleAuthMessage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch wrapper that handles auth errors with iframe-based re-authentication.
|
||||
* Loops until successful or user cancels authentication.
|
||||
*
|
||||
* @param {string|URL} url - The URL to fetch
|
||||
* @param {RequestInit} [options] - Fetch options
|
||||
* @param {number} [options.timeout] - Timeout in ms (default: 10000, use 0 to disable)
|
||||
* @returns {Promise<Response>} - The fetch response
|
||||
* @throws {AuthCancelledError} - If authentication is cancelled by user
|
||||
* @throws {NetworkError} - If network error or timeout occurs
|
||||
*/
|
||||
export async function apiFetch(url, options = {}) {
|
||||
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
|
||||
|
||||
// Ensure credentials are included for cookie-based auth
|
||||
fetchOptions.credentials = fetchOptions.credentials || 'include'
|
||||
|
||||
while (true) {
|
||||
let response
|
||||
try {
|
||||
response = await fetch(url, {...fetchOptions, signal: timeout && AbortSignal.timeout(timeout)})
|
||||
} catch (error) {
|
||||
// Handle network errors and timeouts
|
||||
if (error.name === 'TimeoutError') {
|
||||
throw new NetworkError('Request timed out', error)
|
||||
}
|
||||
if (error.name === 'AbortError') {
|
||||
// Re-throw abort errors as-is (user-initiated cancellation)
|
||||
throw error
|
||||
}
|
||||
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
|
||||
throw new NetworkError('Unable to connect to server', error)
|
||||
}
|
||||
throw new NetworkError(error.message || 'Network error', error)
|
||||
}
|
||||
|
||||
// Check for auth errors (401/403)
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Try to parse the response to get the iframe URL
|
||||
let authInfo = null
|
||||
try {
|
||||
const data = await response.clone().json()
|
||||
authInfo = data.auth
|
||||
} catch {
|
||||
// If we can't parse JSON, no iframe available
|
||||
}
|
||||
|
||||
// Authenticate via iframe (only in top-level window)
|
||||
if (authInfo?.iframe && window === window.top) {
|
||||
// Show auth iframe (or wait for existing one) and retry on success
|
||||
// showAuthIframe returns existing promise if iframe is already open
|
||||
await showAuthIframe(authInfo.iframe)
|
||||
continue // Retry the original request
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for JSON API calls.
|
||||
* Automatically sets Accept and Content-Type headers.
|
||||
* Returns parsed JSON directly if response is ok, throws ApiError otherwise.
|
||||
*
|
||||
* @param {string|URL} url - The URL to fetch
|
||||
* @param {RequestInit} [options] - Fetch options
|
||||
* @returns {Promise<any>} - Parsed JSON response
|
||||
* @throws {ApiError} - If response is not ok
|
||||
* @throws {NetworkError} - If network error or timeout occurs
|
||||
* @throws {AuthCancelledError} - If authentication is cancelled by user
|
||||
*/
|
||||
export async function apiJson(url, options = {}) {
|
||||
const fetchOptions = { ...options }
|
||||
|
||||
// Set default headers, allowing caller overrides
|
||||
fetchOptions.headers = {
|
||||
'Accept': 'application/json',
|
||||
...fetchOptions.headers,
|
||||
}
|
||||
|
||||
// Set Content-Type for requests with JSON body
|
||||
if (fetchOptions.body && typeof fetchOptions.body === 'object' && !(fetchOptions.body instanceof FormData)) {
|
||||
fetchOptions.headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...fetchOptions.headers,
|
||||
}
|
||||
fetchOptions.body = JSON.stringify(fetchOptions.body)
|
||||
}
|
||||
|
||||
const response = await apiFetch(url, fetchOptions)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(url, response, data)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple JSON fetch without auto-auth iframe handling.
|
||||
* Use this in contexts where showing an auth iframe would be inappropriate
|
||||
* (e.g., inside the auth iframe itself).
|
||||
*
|
||||
* @param {string|URL} url - The URL to fetch
|
||||
* @param {RequestInit} [options] - Fetch options
|
||||
* @returns {Promise<any>} - Parsed JSON response
|
||||
* @throws {ApiError} - If response is not ok
|
||||
*/
|
||||
export async function fetchJson(url, options = {}) {
|
||||
const fetchOptions = {
|
||||
credentials: 'include',
|
||||
...options,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(url, response, data)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an error to a user-friendly message.
|
||||
* @param {Error} error - The error to convert
|
||||
* @returns {string} - User-friendly error message
|
||||
*/
|
||||
export function getUserFriendlyErrorMessage(error) {
|
||||
if (error instanceof NetworkError) {
|
||||
return error.message
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
return error.message
|
||||
}
|
||||
if (error.name === 'TimeoutError') {
|
||||
return 'Request timed out'
|
||||
}
|
||||
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
|
||||
return 'Unable to connect to server'
|
||||
}
|
||||
return error.message || 'An error occurred'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error should show a toast to the user.
|
||||
* @param {Error} error - The error to check
|
||||
* @returns {boolean} - Whether to show a toast
|
||||
*/
|
||||
export function shouldShowErrorToast(error) {
|
||||
// Don't show toast for user cancellations
|
||||
if (error instanceof AuthCancelledError) return false
|
||||
if (error.name === 'AbortError') return false
|
||||
// Don't show toast for 401/403 errors - the auth iframe will handle these
|
||||
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an API caller with error handling (toast + console.error).
|
||||
* Wraps apiJson calls with consistent error handling for apps.
|
||||
*
|
||||
* @param {Function} showMessage - Function to show toast messages: (message, type, duration) => void
|
||||
* @returns {Function} - Wrapped apiJson that handles errors
|
||||
*/
|
||||
export function createApiCaller(showMessage) {
|
||||
/**
|
||||
* @param {string|URL} url - The URL to fetch
|
||||
* @param {RequestInit} [options] - Fetch options
|
||||
* @returns {Promise<any>} - Parsed JSON response, or undefined on error
|
||||
*/
|
||||
return async function apiCall(url, options = {}) {
|
||||
try {
|
||||
return await apiJson(url, options)
|
||||
} catch (error) {
|
||||
if (!shouldShowErrorToast(error)) {
|
||||
throw error
|
||||
}
|
||||
// Log full error details
|
||||
console.error(`API error for ${url}:`, error instanceof ApiError ? { status: error.status, statusText: error.statusText, data: error.data } : error)
|
||||
// Show user-friendly toast
|
||||
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default apiFetch
|
||||
@@ -5,8 +5,8 @@ class AwaitableWebSocket extends WebSocket {
|
||||
#opened = false
|
||||
|
||||
constructor(resolve, reject, url, protocols, binaryType) {
|
||||
// Support relative URLs even on old browsers that don't
|
||||
super(new URL(url, location.href.replace(/^http/, 'ws')), protocols)
|
||||
// Support relative URLs even on old browsers that don't natively support them
|
||||
super(new URL(url, document.baseURI.replace(/^http/, 'ws')), protocols)
|
||||
this.binaryType = binaryType || 'blob'
|
||||
this.onopen = () => {
|
||||
this.#opened = true
|
||||
@@ -51,17 +51,12 @@ class AwaitableWebSocket extends WebSocket {
|
||||
console.error("WebSocket received binary data, expected JSON string", data)
|
||||
throw new Error("WebSocket received binary data, expected JSON string")
|
||||
}
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(data)
|
||||
return JSON.parse(data)
|
||||
} catch (err) {
|
||||
console.error("Failed to parse JSON from WebSocket message", data, err)
|
||||
throw new Error("Failed to parse JSON from WebSocket message")
|
||||
}
|
||||
if (parsed.detail) {
|
||||
throw new Error(`Server: ${parsed.detail}`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
send_json(data) {
|
||||
|
||||
@@ -5,16 +5,18 @@ export function formatDate(dateString) {
|
||||
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now - date
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
const diffMs = date - now // Changed to date - now for future/past
|
||||
const isFuture = diffMs > 0
|
||||
const absDiffMs = Math.abs(diffMs)
|
||||
const diffMinutes = Math.round(absDiffMs / (1000 * 60))
|
||||
const diffHours = Math.round(absDiffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.round(absDiffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffMs < 0 || diffDays > 7) return date.toLocaleDateString()
|
||||
if (diffMinutes === 0) return 'Just now'
|
||||
if (diffMinutes < 60) return diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago`
|
||||
if (diffHours < 24) return diffHours === 1 ? 'an hour ago' : `${diffHours} hours ago`
|
||||
return diffDays === 1 ? 'a day ago' : `${diffDays} days ago`
|
||||
if (absDiffMs < 1000 * 60) return 'Now'
|
||||
if (diffMinutes <= 60) return isFuture ? `In ${diffMinutes} minute${diffMinutes === 1 ? '' : 's'}` : diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago`
|
||||
if (diffHours <= 24) return isFuture ? `In ${diffHours} hour${diffHours === 1 ? '' : 's'}` : diffHours === 1 ? 'an hour ago' : `${diffHours} hours ago`
|
||||
if (diffDays <= 14) return isFuture ? `In ${diffDays} day${diffDays === 1 ? '' : 's'}` : diffDays === 1 ? 'a day ago' : `${diffDays} days ago`
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
}
|
||||
|
||||
export function getCookie(name) {
|
||||
@@ -22,3 +24,5 @@ export function getCookie(name) {
|
||||
const parts = value.split(`; ${name}=`)
|
||||
if (parts.length === 2) return parts.pop().split(';').shift()
|
||||
}
|
||||
|
||||
export const goBack = () => history.back() || window.close()
|
||||
|
||||
@@ -1,34 +1,76 @@
|
||||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
||||
import aWebSocket from '@/utils/awaitable-websocket'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
import { showAuthIframe } from '@/utils/api'
|
||||
|
||||
export async function register(resetToken = null, displayName = null) {
|
||||
// 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.
|
||||
async function makeUrl(path) {
|
||||
const s = await getSettings()
|
||||
const h = s?.auth_host
|
||||
return h && location.host !== h ? `//${h}${path}` : path
|
||||
}
|
||||
|
||||
export async function register(resetToken = null, displayName = null, onstartreg = null) {
|
||||
let params = []
|
||||
if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`)
|
||||
if (displayName) params.push(`name=${encodeURIComponent(displayName)}`)
|
||||
const qs = params.length ? `?${params.join('&')}` : ''
|
||||
const url = `/auth/ws/register${qs}`
|
||||
const ws = await aWebSocket(url)
|
||||
try {
|
||||
const optionsJSON = await ws.receive_json()
|
||||
const registrationResponse = await startRegistration({ optionsJSON })
|
||||
ws.send_json(registrationResponse)
|
||||
return await ws.receive_json()
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error)
|
||||
// Replace useless and ugly error message from startRegistration
|
||||
throw Error(error.name === "NotAllowedError" ? 'Passkey registration cancelled' : error.message)
|
||||
} finally {
|
||||
ws.close()
|
||||
|
||||
while (true) {
|
||||
const ws = await aWebSocket(await makeUrl(`/auth/ws/register${qs}`))
|
||||
try {
|
||||
const res = await ws.receive_json()
|
||||
|
||||
// Handle auth errors (401/403) with iframe
|
||||
if ((res.status === 401 || res.status === 403) && res.auth?.iframe) {
|
||||
ws.close()
|
||||
await showAuthIframe(res.auth.iframe)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle other errors (status field present means error)
|
||||
if (res.status) {
|
||||
throw new Error(res.detail || `Registration failed: ${res.status}`)
|
||||
}
|
||||
|
||||
// Notify caller that we're about to show the browser prompt
|
||||
if (onstartreg) onstartreg()
|
||||
|
||||
const registrationResponse = await startRegistration(res)
|
||||
ws.send_json(registrationResponse)
|
||||
|
||||
const result = await ws.receive_json()
|
||||
if (result.status) {
|
||||
throw new Error(result.detail || `Registration failed: ${result.status}`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
ws.close()
|
||||
console.error('Registration error:', error)
|
||||
// Replace useless and ugly error message from startRegistration
|
||||
throw Error(error.name === "NotAllowedError" ? 'Passkey registration cancelled' : error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function authenticate() {
|
||||
const ws = await aWebSocket('/auth/ws/authenticate')
|
||||
const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate'))
|
||||
try {
|
||||
const optionsJSON = await ws.receive_json()
|
||||
const authResponse = await startAuthentication({ optionsJSON })
|
||||
const res = await ws.receive_json()
|
||||
console.log('Authentication options:', res)
|
||||
// status field present means error
|
||||
if (res.status) {
|
||||
throw new Error(res.detail || `Authentication failed: ${res.status}`)
|
||||
}
|
||||
|
||||
const authResponse = await startAuthentication(res)
|
||||
ws.send_json(authResponse)
|
||||
|
||||
const result = await ws.receive_json()
|
||||
if (result.status) {
|
||||
throw new Error(result.detail || `Authentication failed: ${result.status}`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
let _settingsPromise = null
|
||||
let _settings = null
|
||||
|
||||
export function getSettingsCached() { return _settings }
|
||||
|
||||
export async function getSettings() {
|
||||
if (_settings) return _settings
|
||||
if (_settingsPromise) return _settingsPromise
|
||||
_settingsPromise = fetch('/auth/api/settings')
|
||||
.then(r => (r.ok ? r.json() : {}))
|
||||
.then(obj => { _settings = obj || {}; return _settings })
|
||||
.catch(() => { _settings = {}; return _settings })
|
||||
return _settingsPromise
|
||||
}
|
||||
|
||||
export function uiBasePath() {
|
||||
const base = _settings?.ui_base_path || '/auth/'
|
||||
if (base === '/') return '/'
|
||||
return base.endsWith('/') ? base : base + '/'
|
||||
}
|
||||
|
||||
export function adminUiPath() { return uiBasePath() === '/' ? '/admin/' : uiBasePath() + 'admin/' }
|
||||
|
||||
export function makeUiHref(suffix = '') {
|
||||
const trimmed = suffix.startsWith('/') ? suffix.slice(1) : suffix
|
||||
if (!trimmed) return uiBasePath()
|
||||
if (uiBasePath() === '/') return '/' + trimmed
|
||||
return uiBasePath() + trimmed
|
||||
}
|
||||
+69
-29
@@ -1,55 +1,95 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import { resolve } from 'node:path'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { existsSync, renameSync, mkdirSync } from 'node:fs'
|
||||
import sirv from 'sirv'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ command, mode }) => ({
|
||||
export default defineConfig(({ command }) => ({
|
||||
appType: 'mpa',
|
||||
publicDir: 'public',
|
||||
plugins: [
|
||||
vue(),
|
||||
{
|
||||
name: 'serve-examples',
|
||||
configureServer(server) {
|
||||
const examplesDir = resolve(__dirname, '../examples')
|
||||
const serve = sirv(examplesDir, { dev: true })
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
if (req.url === '/' || req.url === '/index.html') req.url = '/examples/'
|
||||
next()
|
||||
})
|
||||
server.middlewares.use('/examples', serve)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'move-html-files',
|
||||
closeBundle() {
|
||||
if (command !== 'build') return
|
||||
|
||||
const outDir = resolve(__dirname, '../passkey/frontend-build')
|
||||
const moves = [
|
||||
{ from: 'auth.html', to: 'auth/index.html' },
|
||||
{ from: 'admin.html', to: 'admin/index.html' },
|
||||
{ from: 'restricted.html', to: 'restricted/index.html' },
|
||||
{ from: 'reset.html', to: 'reset/index.html' },
|
||||
{ from: 'forward.html', to: 'forward/index.html' }
|
||||
]
|
||||
|
||||
for (const { from, to } of moves) {
|
||||
const fromPath = resolve(outDir, from)
|
||||
const toPath = resolve(outDir, to)
|
||||
if (existsSync(fromPath)) {
|
||||
mkdirSync(resolve(outDir, to.split('/')[0]), { recursive: true })
|
||||
renameSync(fromPath, toPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }
|
||||
},
|
||||
// Use absolute paths at dev, deploy under /auth/
|
||||
base: command === 'build' ? '/auth/' : '/',
|
||||
base: '/',
|
||||
server: {
|
||||
port: 4403,
|
||||
fs: {
|
||||
allow: ['..']
|
||||
},
|
||||
proxy: {
|
||||
'/auth/': {
|
||||
// Only proxy these two specific backend API paths
|
||||
'/auth/api': {
|
||||
target: 'http://localhost:4402',
|
||||
ws: true,
|
||||
changeOrigin: false,
|
||||
// We proxy API + WS under /auth/, but want Vite to serve the SPA entrypoints
|
||||
// and static assets so that HMR works. Bypass tells http-proxy to skip
|
||||
// proxying when we return a (possibly rewritten) local path.
|
||||
bypass(req) {
|
||||
const rawUrl = req.url || ''
|
||||
// Strip query/hash to match path-only for SPA entrypoints with query params (e.g. ?reset=token)
|
||||
const url = rawUrl.split('?')[0].split('#')[0]
|
||||
// Bypass only root SPA entrypoints + static assets so Vite serves them for HMR.
|
||||
// Admin API endpoints (e.g., /auth/admin/orgs) must still hit backend.
|
||||
if (url === '/auth/' || url === '/auth') return '/'
|
||||
if (url === '/auth/admin' || url === '/auth/admin/') return '/admin/'
|
||||
if (url.startsWith('/auth/assets/')) return url.replace(/^\/auth/, '')
|
||||
// Everything else (including /auth/admin/* APIs) should proxy.
|
||||
}
|
||||
headers: { connection: 'close' }
|
||||
},
|
||||
'/auth/ws': {
|
||||
target: 'http://localhost:4402',
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: '../passkey/frontend-build',
|
||||
emptyOutDir: true,
|
||||
assetsDir: 'assets',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'index.html'),
|
||||
admin: resolve(__dirname, 'admin/index.html')
|
||||
auth: resolve(__dirname, 'auth/index.html'),
|
||||
admin: resolve(__dirname, 'auth/admin/index.html'),
|
||||
restricted: resolve(__dirname, 'auth/restricted/index.html'),
|
||||
reset: resolve(__dirname, 'int/reset/index.html'),
|
||||
forward: resolve(__dirname, 'int/forward/index.html'),
|
||||
},
|
||||
output: {}
|
||||
output: {
|
||||
entryFileNames: (chunkInfo) => {
|
||||
return 'auth/assets/[name]-[hash].js'
|
||||
},
|
||||
chunkFileNames: (chunkInfo) => {
|
||||
return 'auth/assets/[name]-[hash].js'
|
||||
},
|
||||
assetFileNames: (assetInfo) => {
|
||||
return 'auth/assets/[name]-[hash][extname]'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
+69
-25
@@ -8,61 +8,105 @@ independent of any web framework:
|
||||
- Credential management
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from .db import Session
|
||||
from .globals import db
|
||||
from .config import SESSION_LIFETIME
|
||||
from .db import ResetToken, Session
|
||||
from .globals import db, passkey
|
||||
from .util import hostutil
|
||||
from .util.tokens import create_token, reset_key, session_key
|
||||
|
||||
EXPIRES = timedelta(hours=24)
|
||||
EXPIRES = SESSION_LIFETIME
|
||||
|
||||
|
||||
def expires() -> datetime:
|
||||
return datetime.now() + EXPIRES
|
||||
return datetime.now(timezone.utc) + EXPIRES
|
||||
|
||||
|
||||
async def create_session(user_uuid: UUID, credential_uuid: UUID, info: dict) -> str:
|
||||
def reset_expires() -> datetime:
|
||||
from .config import RESET_LIFETIME
|
||||
|
||||
return datetime.now(timezone.utc) + RESET_LIFETIME
|
||||
|
||||
|
||||
def session_expiry(session: Session) -> datetime:
|
||||
"""Calculate the expiration timestamp for a session (UTC aware)."""
|
||||
# After migration all renewed timestamps are timezone-aware UTC
|
||||
return session.renewed + EXPIRES
|
||||
|
||||
|
||||
async def create_session(
|
||||
user_uuid: UUID,
|
||||
credential_uuid: UUID,
|
||||
*,
|
||||
host: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
) -> str:
|
||||
"""Create a new session and return a session token."""
|
||||
normalized_host = hostutil.normalize_host(host)
|
||||
if not normalized_host:
|
||||
raise ValueError("Host required for session creation")
|
||||
hostname = normalized_host.split(":")[0] # Domain names only, IPs aren't supported
|
||||
rp_id = passkey.instance.rp_id
|
||||
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
|
||||
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
|
||||
token = create_token()
|
||||
now = datetime.now(timezone.utc)
|
||||
await db.instance.create_session(
|
||||
user_uuid=user_uuid,
|
||||
credential_uuid=credential_uuid,
|
||||
key=session_key(token),
|
||||
expires=datetime.now() + EXPIRES,
|
||||
info=info,
|
||||
host=normalized_host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
renewed=now,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
async def get_reset(token: str) -> Session:
|
||||
async def get_reset(token: str) -> ResetToken:
|
||||
"""Validate a credential reset token. Returns None if the token is not well formed (i.e. it is another type of token)."""
|
||||
session = await db.instance.get_session(reset_key(token))
|
||||
if not session:
|
||||
raise ValueError("Invalid or expired session token")
|
||||
return session
|
||||
record = await db.instance.get_reset_token(reset_key(token))
|
||||
if record and record.expiry >= datetime.now(timezone.utc):
|
||||
return record
|
||||
raise ValueError("This reset link is invalid or has expired")
|
||||
|
||||
|
||||
async def get_session(token: str) -> Session:
|
||||
async def get_session(token: str, host: str | None = None) -> Session:
|
||||
"""Validate a session token and return session data if valid."""
|
||||
host = hostutil.normalize_host(host)
|
||||
if not host:
|
||||
raise ValueError("Invalid host")
|
||||
session = await db.instance.get_session(session_key(token))
|
||||
if not session:
|
||||
raise ValueError("Invalid or expired session token")
|
||||
return session
|
||||
if session and session_expiry(session) >= datetime.now(timezone.utc):
|
||||
if session.host is None:
|
||||
# First time binding: store exact host:port (or IPv6 form) now.
|
||||
await db.instance.set_session_host(session.key, host)
|
||||
session.host = host
|
||||
elif session.host != host:
|
||||
raise ValueError("Session host mismatch")
|
||||
return session
|
||||
raise ValueError("Your session has expired. Please sign in again!")
|
||||
|
||||
|
||||
async def refresh_session_token(token: str):
|
||||
async def refresh_session_token(token: str, *, ip: str, user_agent: str):
|
||||
"""Refresh a session extending its expiry."""
|
||||
# Get the current session
|
||||
s = await db.instance.update_session(
|
||||
session_key(token), datetime.now() + EXPIRES, {}
|
||||
session_record = await db.instance.get_session(session_key(token))
|
||||
if not session_record:
|
||||
raise ValueError("Session not found or expired")
|
||||
updated = await db.instance.update_session(
|
||||
session_key(token),
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
renewed=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
if not s:
|
||||
if not updated:
|
||||
raise ValueError("Session not found or expired")
|
||||
|
||||
|
||||
async def delete_credential(credential_uuid: UUID, auth: str):
|
||||
async def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
|
||||
"""Delete a specific credential for the current user."""
|
||||
s = await get_session(auth)
|
||||
s = await get_session(auth, host=host)
|
||||
await db.instance.delete_credential(credential_uuid, s.user_uuid)
|
||||
|
||||
@@ -8,13 +8,13 @@ generating a reset link for initial admin setup.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import uuid7
|
||||
|
||||
from . import authsession, globals
|
||||
from .db import Org, Permission, Role, User
|
||||
from .util import passphrase, tokens
|
||||
from .util import hostutil, passphrase, tokens
|
||||
|
||||
|
||||
def _init_logger() -> logging.Logger:
|
||||
@@ -41,13 +41,14 @@ ADMIN_RESET_MESSAGE = """\
|
||||
async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> str:
|
||||
"""Create an admin reset link and log it with the provided message."""
|
||||
token = passphrase.generate()
|
||||
await globals.db.instance.create_session(
|
||||
expiry = authsession.reset_expires()
|
||||
await globals.db.instance.create_reset_token(
|
||||
user_uuid=user_uuid,
|
||||
key=tokens.reset_key(token),
|
||||
expires=authsession.expires(),
|
||||
info={"type": session_type},
|
||||
expiry=expiry,
|
||||
token_type=session_type,
|
||||
)
|
||||
reset_link = f"{globals.passkey.instance.origin}/auth/{token}"
|
||||
reset_link = hostutil.reset_link_url(token)
|
||||
logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
|
||||
return reset_link
|
||||
|
||||
@@ -90,7 +91,7 @@ async def bootstrap_system(
|
||||
uuid=uuid7.create(),
|
||||
display_name=user_name or "Admin",
|
||||
role_uuid=role.uuid,
|
||||
created_at=datetime.now(),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
visits=0,
|
||||
)
|
||||
await globals.db.instance.create_user(user)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from datetime import timedelta
|
||||
|
||||
# Shared configuration constants for session management.
|
||||
SESSION_LIFETIME = timedelta(hours=24)
|
||||
|
||||
# Lifetime for reset links created by admins
|
||||
RESET_LIFETIME = timedelta(days=14)
|
||||
+91
-29
@@ -63,9 +63,27 @@ class Credential:
|
||||
class Session:
|
||||
key: bytes
|
||||
user_uuid: UUID
|
||||
expires: datetime
|
||||
info: dict
|
||||
credential_uuid: UUID | None = None
|
||||
credential_uuid: UUID
|
||||
host: str
|
||||
ip: str
|
||||
user_agent: str
|
||||
renewed: datetime
|
||||
|
||||
def metadata(self) -> dict:
|
||||
"""Return session metadata for backwards compatibility."""
|
||||
return {
|
||||
"ip": self.ip,
|
||||
"user_agent": self.user_agent,
|
||||
"renewed": self.renewed.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResetToken:
|
||||
key: bytes
|
||||
user_uuid: UUID
|
||||
expiry: datetime
|
||||
token_type: str
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -74,6 +92,7 @@ class SessionContext:
|
||||
user: User
|
||||
org: Org
|
||||
role: Role
|
||||
credential: Credential | None = None
|
||||
permissions: list[Permission] | None = None
|
||||
|
||||
|
||||
@@ -146,9 +165,11 @@ class DatabaseInterface(ABC):
|
||||
self,
|
||||
user_uuid: UUID,
|
||||
key: bytes,
|
||||
expires: datetime,
|
||||
info: dict,
|
||||
credential_uuid: UUID | None = None,
|
||||
credential_uuid: UUID,
|
||||
host: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
renewed: datetime,
|
||||
) -> None:
|
||||
"""Create a new session."""
|
||||
|
||||
@@ -162,14 +183,50 @@ class DatabaseInterface(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def update_session(
|
||||
self, key: bytes, expires: datetime, info: dict
|
||||
self,
|
||||
key: bytes,
|
||||
*,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
renewed: datetime,
|
||||
) -> Session | None:
|
||||
"""Update session expiry and info."""
|
||||
"""Update session metadata and touch renewed timestamp."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_session_host(self, key: bytes, host: str) -> None:
|
||||
"""Bind a session to a specific host if not already set."""
|
||||
|
||||
@abstractmethod
|
||||
async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]:
|
||||
"""Return all sessions for a user (including other hosts)."""
|
||||
|
||||
@abstractmethod
|
||||
async def cleanup(self) -> None:
|
||||
"""Called periodically to clean up expired records."""
|
||||
|
||||
@abstractmethod
|
||||
async def delete_sessions_for_user(self, user_uuid: UUID) -> None:
|
||||
"""Delete all sessions belonging to the provided user."""
|
||||
|
||||
# Reset token operations
|
||||
@abstractmethod
|
||||
async def create_reset_token(
|
||||
self,
|
||||
user_uuid: UUID,
|
||||
key: bytes,
|
||||
expiry: datetime,
|
||||
token_type: str,
|
||||
) -> None:
|
||||
"""Create a reset token for a user."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_reset_token(self, key: bytes) -> ResetToken | None:
|
||||
"""Retrieve a reset token by key."""
|
||||
|
||||
@abstractmethod
|
||||
async def delete_reset_token(self, key: bytes) -> None:
|
||||
"""Delete a reset token by key."""
|
||||
|
||||
# Organization operations
|
||||
@abstractmethod
|
||||
async def create_organization(self, org: Org) -> None:
|
||||
@@ -315,36 +372,41 @@ class DatabaseInterface(ABC):
|
||||
"""Create a new user and their first credential in a transaction."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_session_context(self, session_key: bytes) -> SessionContext | None:
|
||||
async def get_session_context(
|
||||
self, session_key: bytes, host: str | None = None
|
||||
) -> SessionContext | None:
|
||||
"""Get complete session context including user, organization, role, and permissions."""
|
||||
|
||||
# Combined atomic operations
|
||||
@abstractmethod
|
||||
async def create_credential_session(
|
||||
self,
|
||||
user_uuid: UUID,
|
||||
credential: Credential,
|
||||
reset_key: bytes | None,
|
||||
session_key: bytes,
|
||||
session_expires: datetime,
|
||||
session_info: dict,
|
||||
display_name: str | None = None,
|
||||
) -> None:
|
||||
"""Atomically add a credential and create a session.
|
||||
# Combined atomic operations
|
||||
@abstractmethod
|
||||
async def create_credential_session(
|
||||
self,
|
||||
user_uuid: UUID,
|
||||
credential: Credential,
|
||||
reset_key: bytes | None,
|
||||
session_key: bytes,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
host: str | None = None,
|
||||
ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
) -> None:
|
||||
"""Atomically add a credential and create a session.
|
||||
|
||||
Steps (single transaction):
|
||||
1. Insert credential
|
||||
2. Optionally delete old session (e.g. reset token) if provided
|
||||
3. Optionally update user's display name
|
||||
4. Insert new session referencing the credential
|
||||
5. Update user's last_seen and increment visits (treat as a login)
|
||||
"""
|
||||
Steps (single transaction):
|
||||
1. Insert credential
|
||||
2. Optionally delete old reset token if provided
|
||||
3. Optionally update user's display name
|
||||
4. Insert new session referencing the credential
|
||||
5. Update user's last_seen and increment visits (treat as a login)
|
||||
"""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Credential",
|
||||
"Session",
|
||||
"ResetToken",
|
||||
"SessionContext",
|
||||
"Org",
|
||||
"Role",
|
||||
|
||||
+281
-68
@@ -6,7 +6,7 @@ for managing users and credentials in a WebAuthn authentication system.
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import (
|
||||
@@ -17,19 +17,23 @@ from sqlalchemy import (
|
||||
String,
|
||||
delete,
|
||||
event,
|
||||
insert,
|
||||
select,
|
||||
text,
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.dialects.sqlite import BLOB, JSON
|
||||
from sqlalchemy.dialects.sqlite import BLOB
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
from ..config import SESSION_LIFETIME
|
||||
from ..globals import db
|
||||
from . import (
|
||||
Credential,
|
||||
DatabaseInterface,
|
||||
Org,
|
||||
Permission,
|
||||
ResetToken,
|
||||
Role,
|
||||
Session,
|
||||
SessionContext,
|
||||
@@ -39,6 +43,14 @@ from . import (
|
||||
DB_PATH = "sqlite+aiosqlite:///passkey-auth.sqlite"
|
||||
|
||||
|
||||
def _normalize_dt(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
async def init(*args, **kwargs):
|
||||
db.instance = DB()
|
||||
await db.instance.init_db()
|
||||
@@ -97,8 +109,12 @@ class UserModel(Base):
|
||||
role_uuid: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
|
||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
last_seen: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
def as_dataclass(self) -> User:
|
||||
@@ -106,8 +122,8 @@ class UserModel(Base):
|
||||
uuid=UUID(bytes=self.uuid),
|
||||
display_name=self.display_name,
|
||||
role_uuid=UUID(bytes=self.role_uuid),
|
||||
created_at=self.created_at,
|
||||
last_seen=self.last_seen,
|
||||
created_at=_normalize_dt(self.created_at) or self.created_at,
|
||||
last_seen=_normalize_dt(self.last_seen) or self.last_seen,
|
||||
visits=self.visits,
|
||||
)
|
||||
|
||||
@@ -117,7 +133,7 @@ class UserModel(Base):
|
||||
uuid=user.uuid.bytes,
|
||||
display_name=user.display_name,
|
||||
role_uuid=user.role_uuid.bytes,
|
||||
created_at=user.created_at or datetime.now(),
|
||||
created_at=user.created_at or datetime.now(timezone.utc),
|
||||
last_seen=user.last_seen,
|
||||
visits=user.visits,
|
||||
)
|
||||
@@ -136,9 +152,29 @@ class CredentialModel(Base):
|
||||
aaguid: Mapped[bytes] = mapped_column(LargeBinary(16), nullable=False)
|
||||
public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False)
|
||||
sign_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
|
||||
last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_verified: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
# Columns declared timezone-aware going forward; legacy rows may still be naive in storage
|
||||
last_used: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_verified: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
def as_dataclass(self): # type: ignore[override]
|
||||
return Credential(
|
||||
uuid=UUID(bytes=self.uuid),
|
||||
credential_id=self.credential_id,
|
||||
user_uuid=UUID(bytes=self.user_uuid),
|
||||
aaguid=UUID(bytes=self.aaguid),
|
||||
public_key=self.public_key,
|
||||
sign_count=self.sign_count,
|
||||
created_at=_normalize_dt(self.created_at) or self.created_at,
|
||||
last_used=_normalize_dt(self.last_used) or self.last_used,
|
||||
last_verified=_normalize_dt(self.last_verified) or self.last_verified,
|
||||
)
|
||||
|
||||
|
||||
class SessionModel(Base):
|
||||
@@ -146,23 +182,31 @@ class SessionModel(Base):
|
||||
|
||||
key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
|
||||
user_uuid: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE")
|
||||
LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
credential_uuid: Mapped[bytes | None] = mapped_column(
|
||||
LargeBinary(16), ForeignKey("credentials.uuid", ondelete="CASCADE")
|
||||
credential_uuid: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(16),
|
||||
ForeignKey("credentials.uuid", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
host: Mapped[str] = mapped_column(String, nullable=False)
|
||||
ip: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
user_agent: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
renewed: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
expires: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
info: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
def as_dataclass(self):
|
||||
return Session(
|
||||
key=self.key,
|
||||
user_uuid=UUID(bytes=self.user_uuid),
|
||||
credential_uuid=(
|
||||
UUID(bytes=self.credential_uuid) if self.credential_uuid else None
|
||||
),
|
||||
expires=self.expires,
|
||||
info=self.info,
|
||||
credential_uuid=UUID(bytes=self.credential_uuid),
|
||||
host=self.host,
|
||||
ip=self.ip,
|
||||
user_agent=self.user_agent,
|
||||
renewed=_normalize_dt(self.renewed) or self.renewed,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -170,9 +214,30 @@ class SessionModel(Base):
|
||||
return SessionModel(
|
||||
key=session.key,
|
||||
user_uuid=session.user_uuid.bytes,
|
||||
credential_uuid=session.credential_uuid and session.credential_uuid.bytes,
|
||||
expires=session.expires,
|
||||
info=session.info,
|
||||
credential_uuid=session.credential_uuid.bytes,
|
||||
host=session.host,
|
||||
ip=session.ip,
|
||||
user_agent=session.user_agent,
|
||||
renewed=session.renewed,
|
||||
)
|
||||
|
||||
|
||||
class ResetTokenModel(Base):
|
||||
__tablename__ = "reset_tokens"
|
||||
|
||||
key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
|
||||
user_uuid: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
token_type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
def as_dataclass(self) -> ResetToken:
|
||||
return ResetToken(
|
||||
key=self.key,
|
||||
user_uuid=UUID(bytes=self.user_uuid),
|
||||
token_type=self.token_type,
|
||||
expiry=_normalize_dt(self.expiry) or self.expiry,
|
||||
)
|
||||
|
||||
|
||||
@@ -256,6 +321,58 @@ class DB(DatabaseInterface):
|
||||
"""Initialize database tables."""
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
result = await conn.execute(text("PRAGMA table_info('sessions')"))
|
||||
columns = {row[1] for row in result}
|
||||
expected = {
|
||||
"key",
|
||||
"user_uuid",
|
||||
"credential_uuid",
|
||||
"host",
|
||||
"ip",
|
||||
"user_agent",
|
||||
"renewed",
|
||||
}
|
||||
needs_recreate = False
|
||||
if columns and columns != expected:
|
||||
await conn.execute(text("DROP TABLE sessions"))
|
||||
needs_recreate = True
|
||||
result = await conn.execute(text("PRAGMA table_info('reset_tokens')"))
|
||||
if not list(result):
|
||||
needs_recreate = True
|
||||
if needs_recreate:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Run one-time migration to add UTC tzinfo to any naive datetimes
|
||||
await self._migrate_naive_datetimes()
|
||||
|
||||
async def _migrate_naive_datetimes(self) -> None:
|
||||
"""Attach UTC tzinfo to any legacy naive datetime rows.
|
||||
|
||||
SQLite stores datetimes as text; older rows may have been inserted naive.
|
||||
We treat naive timestamps as already UTC and rewrite them in ISO8601 with Z.
|
||||
"""
|
||||
# Helper SQL fragment for detecting naive (no timezone offset) for ISO strings
|
||||
# We only update rows whose textual representation lacks a 'Z' or '+' sign.
|
||||
async with self.session() as session:
|
||||
# Users
|
||||
for model, fields in [
|
||||
(UserModel, ["created_at", "last_seen"]),
|
||||
(CredentialModel, ["created_at", "last_used", "last_verified"]),
|
||||
(SessionModel, ["renewed"]),
|
||||
(ResetTokenModel, ["expiry"]),
|
||||
]:
|
||||
stmt = select(model)
|
||||
result = await session.execute(stmt)
|
||||
rows = result.scalars().all()
|
||||
dirty = False
|
||||
for row in rows:
|
||||
for fname in fields:
|
||||
value = getattr(row, fname, None)
|
||||
if isinstance(value, datetime) and value.tzinfo is None:
|
||||
setattr(row, fname, value.replace(tzinfo=timezone.utc))
|
||||
dirty = True
|
||||
if dirty:
|
||||
# SQLAlchemy autoflush/commit in context manager will persist
|
||||
pass
|
||||
|
||||
async def get_user_by_uuid(self, user_uuid: UUID) -> User:
|
||||
async with self.session() as session:
|
||||
@@ -322,7 +439,7 @@ class DB(DatabaseInterface):
|
||||
credential_model = result.scalar_one_or_none()
|
||||
|
||||
if not credential_model:
|
||||
raise ValueError("Credential not registered")
|
||||
raise ValueError("Credential not found")
|
||||
return Credential(
|
||||
uuid=UUID(bytes=credential_model.uuid),
|
||||
credential_id=credential_model.credential_id,
|
||||
@@ -408,9 +525,11 @@ class DB(DatabaseInterface):
|
||||
credential: Credential,
|
||||
reset_key: bytes | None,
|
||||
session_key: bytes,
|
||||
session_expires: datetime,
|
||||
session_info: dict,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
host: str | None = None,
|
||||
ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
) -> None:
|
||||
"""Atomic credential + (optional old session delete) + (optional rename) + new session."""
|
||||
async with self.session() as session:
|
||||
@@ -433,10 +552,10 @@ class DB(DatabaseInterface):
|
||||
last_verified=credential.last_verified,
|
||||
)
|
||||
)
|
||||
# Delete old session if provided
|
||||
# Delete old reset token if provided
|
||||
if reset_key:
|
||||
await session.execute(
|
||||
delete(SessionModel).where(SessionModel.key == reset_key)
|
||||
delete(ResetTokenModel).where(ResetTokenModel.key == reset_key)
|
||||
)
|
||||
# Optional rename
|
||||
if display_name:
|
||||
@@ -451,8 +570,9 @@ class DB(DatabaseInterface):
|
||||
key=session_key,
|
||||
user_uuid=user_uuid.bytes,
|
||||
credential_uuid=credential.uuid.bytes,
|
||||
expires=session_expires,
|
||||
info=session_info,
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
)
|
||||
# Login side-effects: update user analytics (last_seen + visits increment)
|
||||
@@ -475,17 +595,21 @@ class DB(DatabaseInterface):
|
||||
self,
|
||||
user_uuid: UUID,
|
||||
key: bytes,
|
||||
expires: datetime,
|
||||
info: dict,
|
||||
credential_uuid: UUID | None = None,
|
||||
credential_uuid: UUID,
|
||||
host: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
renewed: datetime,
|
||||
) -> None:
|
||||
async with self.session() as session:
|
||||
session_model = SessionModel(
|
||||
key=key,
|
||||
user_uuid=user_uuid.bytes,
|
||||
credential_uuid=credential_uuid.bytes if credential_uuid else None,
|
||||
expires=expires,
|
||||
info=info,
|
||||
credential_uuid=credential_uuid.bytes,
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
renewed=renewed,
|
||||
)
|
||||
session.add(session_model)
|
||||
|
||||
@@ -496,29 +620,88 @@ class DB(DatabaseInterface):
|
||||
session_model = result.scalar_one_or_none()
|
||||
|
||||
if session_model:
|
||||
return Session(
|
||||
key=session_model.key,
|
||||
user_uuid=UUID(bytes=session_model.user_uuid),
|
||||
credential_uuid=UUID(bytes=session_model.credential_uuid)
|
||||
if session_model.credential_uuid
|
||||
else None,
|
||||
expires=session_model.expires,
|
||||
info=session_model.info or {},
|
||||
)
|
||||
return session_model.as_dataclass()
|
||||
return None
|
||||
|
||||
async def delete_session(self, key: bytes) -> None:
|
||||
async with self.session() as session:
|
||||
await session.execute(delete(SessionModel).where(SessionModel.key == key))
|
||||
|
||||
async def update_session(self, key: bytes, expires: datetime, info: dict) -> None:
|
||||
async def delete_sessions_for_user(self, user_uuid: UUID) -> None:
|
||||
async with self.session() as session:
|
||||
await session.execute(
|
||||
update(SessionModel)
|
||||
.where(SessionModel.key == key)
|
||||
.values(expires=expires, info=info)
|
||||
delete(SessionModel).where(SessionModel.user_uuid == user_uuid.bytes)
|
||||
)
|
||||
|
||||
async def create_reset_token(
|
||||
self,
|
||||
user_uuid: UUID,
|
||||
key: bytes,
|
||||
expiry: datetime,
|
||||
token_type: str,
|
||||
) -> None:
|
||||
async with self.session() as session:
|
||||
model = ResetTokenModel(
|
||||
key=key,
|
||||
user_uuid=user_uuid.bytes,
|
||||
token_type=token_type,
|
||||
expiry=expiry,
|
||||
)
|
||||
session.add(model)
|
||||
|
||||
async def get_reset_token(self, key: bytes) -> ResetToken | None:
|
||||
async with self.session() as session:
|
||||
stmt = select(ResetTokenModel).where(ResetTokenModel.key == key)
|
||||
result = await session.execute(stmt)
|
||||
model = result.scalar_one_or_none()
|
||||
return model.as_dataclass() if model else None
|
||||
|
||||
async def delete_reset_token(self, key: bytes) -> None:
|
||||
async with self.session() as session:
|
||||
await session.execute(
|
||||
delete(ResetTokenModel).where(ResetTokenModel.key == key)
|
||||
)
|
||||
|
||||
async def update_session(
|
||||
self,
|
||||
key: bytes,
|
||||
*,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
renewed: datetime,
|
||||
) -> Session | None:
|
||||
async with self.session() as session:
|
||||
model = await session.get(SessionModel, key)
|
||||
if not model:
|
||||
return None
|
||||
model.ip = ip
|
||||
model.user_agent = user_agent
|
||||
model.renewed = renewed
|
||||
await session.flush()
|
||||
return model.as_dataclass()
|
||||
|
||||
async def set_session_host(self, key: bytes, host: str) -> None:
|
||||
async with self.session() as session:
|
||||
model = await session.get(SessionModel, key)
|
||||
if model and model.host is None:
|
||||
model.host = host
|
||||
await session.flush()
|
||||
|
||||
async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]:
|
||||
async with self.session() as session:
|
||||
stmt = (
|
||||
select(SessionModel)
|
||||
.where(SessionModel.user_uuid == user_uuid.bytes)
|
||||
.order_by(SessionModel.renewed.desc())
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
session_models = [
|
||||
model
|
||||
for model in result.scalars().all()
|
||||
if model.key.startswith(b"sess")
|
||||
]
|
||||
return [model.as_dataclass() for model in session_models]
|
||||
|
||||
# Organization operations
|
||||
async def create_organization(self, org: Org) -> None:
|
||||
async with self.session() as session:
|
||||
@@ -971,8 +1154,10 @@ class DB(DatabaseInterface):
|
||||
)
|
||||
if role.permissions:
|
||||
for perm_id in set(role.permissions):
|
||||
session.add(
|
||||
RolePermission(role_uuid=role.uuid.bytes, permission_id=perm_id)
|
||||
await session.execute(
|
||||
insert(RolePermission).values(
|
||||
role_uuid=role.uuid.bytes, permission_id=perm_id
|
||||
)
|
||||
)
|
||||
|
||||
async def delete_role(self, role_uuid: UUID) -> None:
|
||||
@@ -1112,29 +1297,41 @@ class DB(DatabaseInterface):
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
async with self.session() as session:
|
||||
current_time = datetime.now()
|
||||
stmt = delete(SessionModel).where(SessionModel.expires < current_time)
|
||||
await session.execute(stmt)
|
||||
current_time = datetime.now(timezone.utc)
|
||||
session_threshold = current_time - SESSION_LIFETIME
|
||||
await session.execute(
|
||||
delete(SessionModel).where(SessionModel.renewed < session_threshold)
|
||||
)
|
||||
await session.execute(
|
||||
delete(ResetTokenModel).where(ResetTokenModel.expiry < current_time)
|
||||
)
|
||||
|
||||
async def get_session_context(self, session_key: bytes) -> SessionContext | None:
|
||||
async def get_session_context(
|
||||
self, session_key: bytes, host: str | None = None
|
||||
) -> SessionContext | None:
|
||||
"""Get complete session context including user, organization, role, and permissions.
|
||||
|
||||
Uses efficient JOINs to retrieve all related data in a single database query.
|
||||
"""
|
||||
async with self.session() as session:
|
||||
# Build a query that joins sessions, users, roles, organizations, and role_permissions
|
||||
# Build a query that joins sessions, users, roles, organizations, credentials and role_permissions
|
||||
stmt = (
|
||||
select(
|
||||
SessionModel,
|
||||
UserModel,
|
||||
RoleModel,
|
||||
OrgModel,
|
||||
CredentialModel,
|
||||
PermissionModel,
|
||||
)
|
||||
.select_from(SessionModel)
|
||||
.join(UserModel, SessionModel.user_uuid == UserModel.uuid)
|
||||
.join(RoleModel, UserModel.role_uuid == RoleModel.uuid)
|
||||
.join(OrgModel, RoleModel.org_uuid == OrgModel.uuid)
|
||||
.outerjoin(
|
||||
CredentialModel,
|
||||
SessionModel.credential_uuid == CredentialModel.uuid,
|
||||
)
|
||||
.outerjoin(RolePermission, RoleModel.uuid == RolePermission.role_uuid)
|
||||
.outerjoin(
|
||||
PermissionModel, RolePermission.permission_id == PermissionModel.id
|
||||
@@ -1150,18 +1347,23 @@ class DB(DatabaseInterface):
|
||||
|
||||
# Extract the first row to get session and user data
|
||||
first_row = rows[0]
|
||||
session_model, user_model, role_model, org_model, _ = first_row
|
||||
session_model, user_model, role_model, org_model, credential_model, _ = (
|
||||
first_row
|
||||
)
|
||||
|
||||
# Create the session object
|
||||
session_obj = Session(
|
||||
key=session_model.key,
|
||||
user_uuid=UUID(bytes=session_model.user_uuid),
|
||||
credential_uuid=UUID(bytes=session_model.credential_uuid)
|
||||
if session_model.credential_uuid
|
||||
else None,
|
||||
expires=session_model.expires,
|
||||
info=session_model.info or {},
|
||||
)
|
||||
if host is not None:
|
||||
if session_model.host is None:
|
||||
await session.execute(
|
||||
update(SessionModel)
|
||||
.where(SessionModel.key == session_key)
|
||||
.values(host=host)
|
||||
)
|
||||
session_model.host = host
|
||||
elif session_model.host != host:
|
||||
return None
|
||||
|
||||
session_obj = session_model.as_dataclass()
|
||||
|
||||
# Create the user object
|
||||
user_obj = user_model.as_dataclass()
|
||||
@@ -1176,11 +1378,16 @@ class DB(DatabaseInterface):
|
||||
display_name=role_model.display_name,
|
||||
)
|
||||
|
||||
# Create credential object if available
|
||||
credential_obj = (
|
||||
credential_model.as_dataclass() if credential_model else None
|
||||
)
|
||||
|
||||
# Collect all unique permissions for the role
|
||||
permissions = []
|
||||
seen_permission_ids = set()
|
||||
for row in rows:
|
||||
_, _, _, _, permission_model = row
|
||||
_, _, _, _, _, permission_model = row
|
||||
if permission_model and permission_model.id not in seen_permission_ids:
|
||||
permissions.append(
|
||||
Permission(
|
||||
@@ -1200,10 +1407,16 @@ class DB(DatabaseInterface):
|
||||
org_perm_result = await session.execute(org_perm_stmt)
|
||||
organization.permissions = [row[0] for row in org_perm_result.fetchall()]
|
||||
|
||||
# Filter effective permissions: only include permissions that the org can grant
|
||||
effective_permissions = [
|
||||
p for p in permissions if p.id in organization.permissions
|
||||
]
|
||||
|
||||
return SessionContext(
|
||||
session=session_obj,
|
||||
user=user_obj,
|
||||
org=organization,
|
||||
role=role,
|
||||
permissions=permissions if permissions else None,
|
||||
credential=credential_obj,
|
||||
permissions=effective_permissions if effective_permissions else None,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,27 @@ DEFAULT_SERVE_PORT = 4401
|
||||
DEFAULT_DEV_PORT = 4402
|
||||
|
||||
|
||||
def is_subdomain(sub: str, domain: str) -> bool:
|
||||
"""Check if sub is a subdomain of domain (or equal)."""
|
||||
sub_parts = sub.lower().split(".")
|
||||
domain_parts = domain.lower().split(".")
|
||||
if len(sub_parts) < len(domain_parts):
|
||||
return False
|
||||
return sub_parts[-len(domain_parts) :] == domain_parts
|
||||
|
||||
|
||||
def validate_auth_host(auth_host: str, rp_id: str) -> None:
|
||||
"""Validate that auth_host is a subdomain of rp_id."""
|
||||
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
|
||||
host = parsed.hostname or parsed.path
|
||||
if not host:
|
||||
raise SystemExit(f"Invalid auth-host: '{auth_host}'")
|
||||
if not is_subdomain(host, rp_id):
|
||||
raise SystemExit(
|
||||
f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
|
||||
)
|
||||
|
||||
|
||||
def parse_endpoint(
|
||||
value: str | None, default_port: int
|
||||
) -> tuple[str | None, int | None, str | None, bool]:
|
||||
@@ -94,6 +115,13 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
|
||||
)
|
||||
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
|
||||
p.add_argument("--origin", help="Origin URL (default: https://<rp-id>)")
|
||||
p.add_argument(
|
||||
"--auth-host",
|
||||
help=(
|
||||
"Dedicated host (optionally with scheme/port) to serve the auth UI at the root,"
|
||||
" e.g. auth.example.com or https://auth.example.com"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -168,6 +196,17 @@ def main():
|
||||
os.environ["PASSKEY_RP_NAME"] = args.rp_name
|
||||
if origin:
|
||||
os.environ["PASSKEY_ORIGIN"] = origin
|
||||
if getattr(args, "auth_host", None):
|
||||
os.environ["PASSKEY_AUTH_HOST"] = args.auth_host
|
||||
else:
|
||||
# Preserve pre-set env variable if CLI option omitted
|
||||
args.auth_host = os.environ.get("PASSKEY_AUTH_HOST")
|
||||
|
||||
if args.auth_host:
|
||||
validate_auth_host(args.auth_host, args.rp_id)
|
||||
from passkey.util import hostutil as _hostutil # local import
|
||||
|
||||
_hostutil.reload_config()
|
||||
|
||||
# One-time initialization + bootstrap before starting any server processes.
|
||||
# Lifespan in worker processes will call globals.init with bootstrap disabled.
|
||||
@@ -194,6 +233,7 @@ def main():
|
||||
if args.command in {"serve", "dev"}:
|
||||
run_kwargs: dict = {
|
||||
"reload": devmode,
|
||||
"reload_dirs": ["passkey"] if devmode else None,
|
||||
"log_level": "info",
|
||||
}
|
||||
if uds:
|
||||
@@ -204,9 +244,8 @@ def main():
|
||||
run_kwargs["port"] = port
|
||||
|
||||
if devmode:
|
||||
if os.environ.get("PASSKEY_BUN_PARENT") != "1":
|
||||
os.environ["PASSKEY_BUN_PARENT"] = "1"
|
||||
frontend.run_dev()
|
||||
os.environ["PASSKEY_DEVMODE"] = "1"
|
||||
frontend.run_dev()
|
||||
|
||||
if all_ifaces and not uds:
|
||||
if devmode:
|
||||
|
||||
+481
-70
@@ -1,14 +1,24 @@
|
||||
import logging
|
||||
from datetime import timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import Body, Cookie, FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi import Body, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ..authsession import expires
|
||||
from ..authsession import reset_expires
|
||||
from ..globals import db
|
||||
from ..globals import passkey as global_passkey
|
||||
from ..util import frontend, passphrase, permutil, querysafe, tokens
|
||||
from ..util import (
|
||||
frontend,
|
||||
hostutil,
|
||||
passphrase,
|
||||
permutil,
|
||||
querysafe,
|
||||
tokens,
|
||||
useragent,
|
||||
)
|
||||
from ..util.tokens import encode_session_key, session_key
|
||||
from . import authz
|
||||
from .session import AUTH_COOKIE
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@@ -18,6 +28,15 @@ async def value_error_handler(_request, exc: ValueError): # pragma: no cover -
|
||||
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
||||
|
||||
|
||||
@app.exception_handler(authz.AuthException)
|
||||
async def auth_exception_handler(_request, exc: authz.AuthException):
|
||||
"""Handle AuthException with auth info for UI."""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=await authz.auth_error_content(exc),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(_request, exc: Exception):
|
||||
logging.exception("Unhandled exception in admin app")
|
||||
@@ -25,20 +44,21 @@ async def general_exception_handler(_request, exc: Exception):
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def adminapp(auth=Cookie(None)):
|
||||
try:
|
||||
await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any)
|
||||
return FileResponse(frontend.file("admin/index.html"))
|
||||
except HTTPException as e:
|
||||
return FileResponse(frontend.file("index.html"), status_code=e.status_code)
|
||||
async def adminapp(request: Request, auth=AUTH_COOKIE):
|
||||
return Response(*await frontend.read("/auth/admin/index.html"))
|
||||
|
||||
|
||||
# -------------------- Organizations --------------------
|
||||
|
||||
|
||||
@app.get("/orgs")
|
||||
async def admin_list_orgs(auth=Cookie(None)):
|
||||
ctx = await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any)
|
||||
async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:*"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
orgs = await db.instance.list_organizations()
|
||||
if "auth:admin" not in ctx.role.permissions:
|
||||
orgs = [o for o in orgs if f"auth:org:{o.uuid}" in ctx.role.permissions]
|
||||
@@ -74,60 +94,125 @@ async def admin_list_orgs(auth=Cookie(None)):
|
||||
|
||||
|
||||
@app.post("/orgs")
|
||||
async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)):
|
||||
await authz.verify(auth, ["auth:admin"])
|
||||
async def admin_create_org(
|
||||
request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
|
||||
):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
from ..db import Org as OrgDC # local import to avoid cycles
|
||||
from ..db import Role as RoleDC # local import to avoid cycles
|
||||
|
||||
org_uuid = uuid4()
|
||||
display_name = payload.get("display_name") or "New Organization"
|
||||
permissions = payload.get("permissions") or []
|
||||
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
||||
await db.instance.create_organization(org)
|
||||
|
||||
# Automatically create Administration role with org admin permission
|
||||
role_uuid = uuid4()
|
||||
admin_role = RoleDC(
|
||||
uuid=role_uuid,
|
||||
org_uuid=org_uuid,
|
||||
display_name="Administration",
|
||||
permissions=[f"auth:org:{org_uuid}"],
|
||||
)
|
||||
await db.instance.create_role(admin_role)
|
||||
|
||||
return {"uuid": str(org_uuid)}
|
||||
|
||||
|
||||
@app.put("/orgs/{org_uuid}")
|
||||
async def admin_update_org(
|
||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
from ..db import Org as OrgDC # local import to avoid cycles
|
||||
|
||||
current = await db.instance.get_organization(str(org_uuid))
|
||||
display_name = payload.get("display_name") or current.display_name
|
||||
permissions = payload.get("permissions") or current.permissions or []
|
||||
|
||||
# Sanity check: prevent removing permissions that would break current user's admin access
|
||||
org_admin_perm = f"auth:org:{org_uuid}"
|
||||
|
||||
# If current user is org admin (not global admin), ensure org admin perm remains
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" in ctx.role.permissions
|
||||
):
|
||||
if org_admin_perm not in permissions:
|
||||
raise ValueError(
|
||||
"Cannot remove organization admin permission from your own organization"
|
||||
)
|
||||
|
||||
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
||||
await db.instance.update_organization(org)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}")
|
||||
async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
|
||||
async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if ctx.org.uuid == org_uuid:
|
||||
raise ValueError("Cannot delete the organization you belong to")
|
||||
|
||||
# Delete organization-specific permissions
|
||||
org_perm_pattern = f"org:{str(org_uuid).lower()}"
|
||||
all_permissions = await db.instance.list_permissions()
|
||||
for perm in all_permissions:
|
||||
perm_id_lower = perm.id.lower()
|
||||
# Check if permission contains "org:{uuid}" separated by colons or at boundaries
|
||||
if (
|
||||
f":{org_perm_pattern}:" in perm_id_lower
|
||||
or perm_id_lower.startswith(f"{org_perm_pattern}:")
|
||||
or perm_id_lower.endswith(f":{org_perm_pattern}")
|
||||
or perm_id_lower == org_perm_pattern
|
||||
):
|
||||
await db.instance.delete_permission(perm.id)
|
||||
|
||||
await db.instance.delete_organization(org_uuid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/orgs/{org_uuid}/permission")
|
||||
async def admin_add_org_permission(
|
||||
org_uuid: UUID, permission_id: str, auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
permission_id: str,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(auth, ["auth:admin"])
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/permission")
|
||||
async def admin_remove_org_permission(
|
||||
org_uuid: UUID, permission_id: str, auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
permission_id: str,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(auth, ["auth:admin"])
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
await db.instance.remove_permission_from_organization(str(org_uuid), permission_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -137,9 +222,17 @@ async def admin_remove_org_permission(
|
||||
|
||||
@app.post("/orgs/{org_uuid}/roles")
|
||||
async def admin_create_role(
|
||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(auth, ["auth:admin", f"auth:org:{org_uuid}"])
|
||||
await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
from ..db import Role as RoleDC
|
||||
|
||||
role_uuid = uuid4()
|
||||
@@ -163,11 +256,18 @@ async def admin_create_role(
|
||||
|
||||
@app.put("/orgs/{org_uuid}/roles/{role_uuid}")
|
||||
async def admin_update_role(
|
||||
org_uuid: UUID, role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
# Verify caller is global admin or admin of provided org
|
||||
await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
role = await db.instance.get_role(role_uuid)
|
||||
if role.org_uuid != org_uuid:
|
||||
@@ -175,13 +275,25 @@ async def admin_update_role(
|
||||
from ..db import Role as RoleDC
|
||||
|
||||
display_name = payload.get("display_name") or role.display_name
|
||||
permissions = payload.get("permissions") or role.permissions
|
||||
permissions = payload.get("permissions")
|
||||
if permissions is None:
|
||||
permissions = role.permissions
|
||||
org = await db.instance.get_organization(str(org_uuid))
|
||||
grantable = set(org.permissions or [])
|
||||
existing_permissions = set(role.permissions)
|
||||
for pid in permissions:
|
||||
await db.instance.get_permission(pid)
|
||||
if pid not in grantable:
|
||||
if pid not in existing_permissions and pid not in grantable:
|
||||
raise ValueError(f"Permission not grantable by org: {pid}")
|
||||
|
||||
# Sanity check: prevent admin from removing their own access via role update
|
||||
if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid:
|
||||
has_admin_access = (
|
||||
"auth:admin" in permissions or f"auth:org:{org_uuid}" in permissions
|
||||
)
|
||||
if not has_admin_access:
|
||||
raise ValueError("Cannot update your own role to remove admin permissions")
|
||||
|
||||
updated = RoleDC(
|
||||
uuid=role_uuid,
|
||||
org_uuid=org_uuid,
|
||||
@@ -193,13 +305,27 @@ async def admin_update_role(
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}")
|
||||
async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
async def admin_delete_role(
|
||||
org_uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
role = await db.instance.get_role(role_uuid)
|
||||
if role.org_uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
||||
|
||||
# Sanity check: prevent admin from deleting their own role
|
||||
if ctx.role.uuid == role_uuid:
|
||||
raise ValueError("Cannot delete your own role")
|
||||
|
||||
await db.instance.delete_role(role_uuid)
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -209,10 +335,16 @@ async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)):
|
||||
|
||||
@app.post("/orgs/{org_uuid}/users")
|
||||
async def admin_create_user(
|
||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
display_name = payload.get("display_name")
|
||||
role_name = payload.get("role")
|
||||
@@ -238,10 +370,17 @@ async def admin_create_user(
|
||||
|
||||
@app.put("/orgs/{org_uuid}/users/{user_uuid}/role")
|
||||
async def admin_update_user_role(
|
||||
org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
new_role = payload.get("role")
|
||||
if not new_role:
|
||||
@@ -255,13 +394,30 @@ async def admin_update_user_role(
|
||||
roles = await db.instance.get_roles_by_organization(str(org_uuid))
|
||||
if not any(r.display_name == new_role for r in roles):
|
||||
raise ValueError("Role not found in organization")
|
||||
|
||||
# Sanity check: prevent admin from removing their own access
|
||||
if ctx.user.uuid == user_uuid:
|
||||
new_role_obj = next((r for r in roles if r.display_name == new_role), None)
|
||||
if new_role_obj:
|
||||
has_admin_access = (
|
||||
"auth:admin" in new_role_obj.permissions
|
||||
or f"auth:org:{org_uuid}" in new_role_obj.permissions
|
||||
)
|
||||
if not has_admin_access:
|
||||
raise ValueError(
|
||||
"Cannot change your own role to one without admin permissions"
|
||||
)
|
||||
|
||||
await db.instance.update_user_role_in_organization(user_uuid, new_role)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link")
|
||||
async def admin_create_user_registration_link(
|
||||
org_uuid: UUID, user_uuid: UUID, auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||
@@ -270,27 +426,52 @@ async def admin_create_user_registration_link(
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
# Check if user has existing credentials
|
||||
credentials = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||
token_type = "user registration" if not credentials else "account recovery"
|
||||
|
||||
token = passphrase.generate()
|
||||
await db.instance.create_session(
|
||||
expiry = reset_expires()
|
||||
await db.instance.create_reset_token(
|
||||
user_uuid=user_uuid,
|
||||
key=tokens.reset_key(token),
|
||||
expires=expires(),
|
||||
info={"type": "device addition", "created_by_admin": True},
|
||||
expiry=expiry,
|
||||
token_type=token_type,
|
||||
)
|
||||
origin = global_passkey.instance.origin
|
||||
url = f"{origin}/auth/{token}"
|
||||
return {"url": url, "expires": expires().isoformat()}
|
||||
url = hostutil.reset_link_url(
|
||||
token, request.url.scheme, request.headers.get("host")
|
||||
)
|
||||
return {
|
||||
"url": url,
|
||||
"expires": (
|
||||
expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if expiry.tzinfo
|
||||
else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/orgs/{org_uuid}/users/{user_uuid}")
|
||||
async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(None)):
|
||||
async def admin_get_user_detail(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user_org, role_name = await db.instance.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
@@ -298,13 +479,18 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
user = await db.instance.get_user_by_uuid(user_uuid)
|
||||
cred_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||
creds: list[dict] = []
|
||||
@@ -320,9 +506,41 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
|
||||
{
|
||||
"credential_uuid": str(c.uuid),
|
||||
"aaguid": aaguid_str,
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"last_used": c.last_used.isoformat() if c.last_used else None,
|
||||
"last_verified": c.last_verified.isoformat()
|
||||
"created_at": (
|
||||
c.created_at.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.created_at.tzinfo
|
||||
else c.created_at.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
"last_used": (
|
||||
c.last_used.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_used and c.last_used.tzinfo
|
||||
else (
|
||||
c.last_used.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_used
|
||||
else None
|
||||
)
|
||||
),
|
||||
"last_verified": (
|
||||
c.last_verified.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_verified and c.last_verified.tzinfo
|
||||
else (
|
||||
c.last_verified.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_verified
|
||||
else None
|
||||
)
|
||||
)
|
||||
if c.last_verified
|
||||
else None,
|
||||
"sign_count": c.sign_count,
|
||||
@@ -331,21 +549,77 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
|
||||
from .. import aaguid as aaguid_mod
|
||||
|
||||
aaguid_info = aaguid_mod.filter(aaguids)
|
||||
|
||||
# Get sessions for the user
|
||||
normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
|
||||
session_records = await db.instance.list_sessions_for_user(user_uuid)
|
||||
current_session_key = session_key(auth)
|
||||
sessions_payload: list[dict] = []
|
||||
for entry in session_records:
|
||||
sessions_payload.append(
|
||||
{
|
||||
"id": encode_session_key(entry.key),
|
||||
"host": entry.host,
|
||||
"ip": entry.ip,
|
||||
"user_agent": useragent.compact_user_agent(entry.user_agent),
|
||||
"last_renewed": (
|
||||
entry.renewed.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if entry.renewed.tzinfo
|
||||
else entry.renewed.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
"is_current": entry.key == current_session_key,
|
||||
"is_current_host": bool(
|
||||
normalized_request_host
|
||||
and entry.host
|
||||
and entry.host == normalized_request_host
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"display_name": user.display_name,
|
||||
"org": {"display_name": user_org.display_name},
|
||||
"role": role_name,
|
||||
"visits": user.visits,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"last_seen": user.last_seen.isoformat() if user.last_seen else None,
|
||||
"created_at": (
|
||||
user.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if user.created_at and user.created_at.tzinfo
|
||||
else (
|
||||
user.created_at.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if user.created_at
|
||||
else None
|
||||
)
|
||||
),
|
||||
"last_seen": (
|
||||
user.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if user.last_seen and user.last_seen.tzinfo
|
||||
else (
|
||||
user.last_seen.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if user.last_seen
|
||||
else None
|
||||
)
|
||||
),
|
||||
"credentials": creds,
|
||||
"aaguid_info": aaguid_info,
|
||||
"sessions": sessions_payload,
|
||||
}
|
||||
|
||||
|
||||
@app.put("/orgs/{org_uuid}/users/{user_uuid}/display-name")
|
||||
async def admin_update_user_display_name(
|
||||
org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||
@@ -354,13 +628,18 @@ async def admin_update_user_display_name(
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
new_name = (payload.get("display_name") or "").strip()
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=400, detail="display_name required")
|
||||
@@ -370,19 +649,120 @@ async def admin_update_user_display_name(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}")
|
||||
async def admin_delete_user_credential(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
credential_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
await db.instance.delete_credential(credential_uuid, user_uuid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/sessions/{session_id}")
|
||||
async def admin_delete_user_session(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
session_id: str,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
try:
|
||||
target_key = tokens.decode_session_key(session_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid session identifier"
|
||||
) from exc
|
||||
|
||||
target_session = await db.instance.get_session(target_key)
|
||||
if not target_session or target_session.user_uuid != user_uuid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
await db.instance.delete_session(target_key)
|
||||
|
||||
# Check if admin terminated their own session
|
||||
current_terminated = target_key == session_key(auth)
|
||||
return {"status": "ok", "current_session_terminated": current_terminated}
|
||||
|
||||
|
||||
# -------------------- Permissions (global) --------------------
|
||||
|
||||
|
||||
@app.get("/permissions")
|
||||
async def admin_list_permissions(auth=Cookie(None)):
|
||||
await authz.verify(auth, ["auth:admin"], match=permutil.has_any)
|
||||
async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:*"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
perms = await db.instance.list_permissions()
|
||||
return [{"id": p.id, "display_name": p.display_name} for p in perms]
|
||||
|
||||
# Global admins see all permissions
|
||||
if "auth:admin" in ctx.role.permissions:
|
||||
return [{"id": p.id, "display_name": p.display_name} for p in perms]
|
||||
|
||||
# Org admins only see permissions their org can grant
|
||||
grantable = set(ctx.org.permissions or [])
|
||||
filtered_perms = [p for p in perms if p.id in grantable]
|
||||
return [{"id": p.id, "display_name": p.display_name} for p in filtered_perms]
|
||||
|
||||
|
||||
@app.post("/permissions")
|
||||
async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)):
|
||||
await authz.verify(auth, ["auth:admin"])
|
||||
async def admin_create_permission(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(
|
||||
auth,
|
||||
["auth:admin"],
|
||||
host=request.headers.get("host"),
|
||||
match=permutil.has_all,
|
||||
max_age="5m",
|
||||
)
|
||||
from ..db import Permission as PermDC
|
||||
|
||||
perm_id = payload.get("id")
|
||||
@@ -396,9 +776,14 @@ async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)):
|
||||
|
||||
@app.put("/permission")
|
||||
async def admin_update_permission(
|
||||
permission_id: str, display_name: str, auth=Cookie(None)
|
||||
permission_id: str,
|
||||
display_name: str,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(auth, ["auth:admin"])
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
from ..db import Permission as PermDC
|
||||
|
||||
if not display_name:
|
||||
@@ -411,13 +796,24 @@ async def admin_update_permission(
|
||||
|
||||
|
||||
@app.post("/permission/rename")
|
||||
async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
|
||||
await authz.verify(auth, ["auth:admin"])
|
||||
async def admin_rename_permission(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
old_id = payload.get("old_id")
|
||||
new_id = payload.get("new_id")
|
||||
display_name = payload.get("display_name")
|
||||
if not old_id or not new_id:
|
||||
raise ValueError("old_id and new_id required")
|
||||
|
||||
# Sanity check: prevent renaming critical permissions
|
||||
if old_id == "auth:admin":
|
||||
raise ValueError("Cannot rename the master admin permission")
|
||||
|
||||
querysafe.assert_safe(old_id, field="old_id")
|
||||
querysafe.assert_safe(new_id, field="new_id")
|
||||
if display_name is None:
|
||||
@@ -431,8 +827,23 @@ async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
|
||||
|
||||
|
||||
@app.delete("/permission")
|
||||
async def admin_delete_permission(permission_id: str, auth=Cookie(None)):
|
||||
await authz.verify(auth, ["auth:admin"])
|
||||
async def admin_delete_permission(
|
||||
permission_id: str,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(
|
||||
auth,
|
||||
["auth:admin"],
|
||||
host=request.headers.get("host"),
|
||||
match=permutil.has_all,
|
||||
max_age="5m",
|
||||
)
|
||||
querysafe.assert_safe(permission_id, field="permission_id")
|
||||
|
||||
# Sanity check: prevent deleting critical permissions
|
||||
if permission_id == "auth:admin":
|
||||
raise ValueError("Cannot delete the master admin permission")
|
||||
|
||||
await db.instance.delete_permission(permission_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
+151
-147
@@ -1,11 +1,8 @@
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import (
|
||||
Body,
|
||||
Cookie,
|
||||
Depends,
|
||||
FastAPI,
|
||||
HTTPException,
|
||||
@@ -13,30 +10,42 @@ from fastapi import (
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
from passkey.util import frontend
|
||||
|
||||
from .. import aaguid
|
||||
from ..authsession import (
|
||||
EXPIRES,
|
||||
delete_credential,
|
||||
expires,
|
||||
get_reset,
|
||||
get_session,
|
||||
refresh_session_token,
|
||||
session_expiry,
|
||||
)
|
||||
from ..globals import db
|
||||
from ..globals import passkey as global_passkey
|
||||
from ..util import passphrase, permutil, tokens
|
||||
from ..util import hostutil, htmlutil, passphrase, userinfo
|
||||
from ..util.tokens import session_key
|
||||
from . import authz, session
|
||||
from . import authz, session, user
|
||||
from .session import AUTH_COOKIE
|
||||
|
||||
bearer_auth = HTTPBearer(auto_error=True)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.mount("/user", user.app)
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(_request: Request, exc: HTTPException):
|
||||
"""Ensure auth cookie is cleared on 401 responses (JSON responses only)."""
|
||||
if exc.status_code == 401:
|
||||
resp = JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||
session.clear_session_cookie(resp)
|
||||
return resp
|
||||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||
|
||||
|
||||
# Refresh only if at least this much of the session lifetime has been *consumed*.
|
||||
# Consumption is derived from (now + EXPIRES) - current_expires.
|
||||
# This guarantees a minimum spacing between DB writes even with frequent /validate calls.
|
||||
@@ -48,6 +57,15 @@ async def value_error_handler(_request: Request, exc: ValueError):
|
||||
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
||||
|
||||
|
||||
@app.exception_handler(authz.AuthException)
|
||||
async def auth_exception_handler(_request: Request, exc: authz.AuthException):
|
||||
"""Handle AuthException with auth info for UI."""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=await authz.auth_error_content(exc),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(_request: Request, exc: Exception):
|
||||
logging.exception("Unhandled exception in API app")
|
||||
@@ -56,7 +74,10 @@ async def general_exception_handler(_request: Request, exc: Exception):
|
||||
|
||||
@app.post("/validate")
|
||||
async def validate_token(
|
||||
response: Response, perm: list[str] = Query([]), auth=Cookie(None)
|
||||
request: Request,
|
||||
response: Response,
|
||||
perm: list[str] = Query([]),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Validate the current session and extend its expiry.
|
||||
|
||||
@@ -64,18 +85,29 @@ async def validate_token(
|
||||
renewed max-age. This keeps active users logged in without needing a separate
|
||||
refresh endpoint.
|
||||
"""
|
||||
ctx = await authz.verify(auth, perm)
|
||||
try:
|
||||
ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
|
||||
except HTTPException:
|
||||
# Global handler will clear cookie if 401
|
||||
raise
|
||||
renewed = False
|
||||
if auth:
|
||||
consumed = EXPIRES - (ctx.session.expires - datetime.now())
|
||||
current_expiry = session_expiry(ctx.session)
|
||||
consumed = EXPIRES - (current_expiry - datetime.now(timezone.utc))
|
||||
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
|
||||
try:
|
||||
await refresh_session_token(auth)
|
||||
await refresh_session_token(
|
||||
auth,
|
||||
ip=request.client.host if request.client else "",
|
||||
user_agent=request.headers.get("user-agent") or "",
|
||||
)
|
||||
session.set_session_cookie(response, auth)
|
||||
renewed = True
|
||||
except ValueError:
|
||||
# Session disappeared, e.g. due to concurrent logout
|
||||
raise HTTPException(status_code=401, detail="Session expired")
|
||||
# Session disappeared, e.g. due to concurrent logout; global handler will clear
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
)
|
||||
return {
|
||||
"valid": True,
|
||||
"user_uuid": str(ctx.session.user_uuid),
|
||||
@@ -84,17 +116,31 @@ async def validate_token(
|
||||
|
||||
|
||||
@app.get("/forward")
|
||||
async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None)):
|
||||
async def forward_authentication(
|
||||
request: Request,
|
||||
response: Response,
|
||||
perm: list[str] = Query([]),
|
||||
max_age: str | None = Query(None),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Forward auth validation for Caddy/Nginx.
|
||||
|
||||
Query Params:
|
||||
- perm: repeated permission IDs the authenticated user must possess (ALL required).
|
||||
- max_age: maximum age of authentication (e.g., "5m", "1h", "30s"). If the session
|
||||
is older than this, user must re-authenticate.
|
||||
|
||||
Success: 204 No Content with Remote-* headers describing the authenticated user.
|
||||
Failure (unauthenticated / unauthorized): 4xx JSON body with detail.
|
||||
Failure (unauthenticated / unauthorized): 4xx response.
|
||||
- If Accept header contains "text/html": HTML page for authentication
|
||||
with data attributes for mode and other metadata.
|
||||
- Otherwise: JSON response with error details and an `iframe` field
|
||||
pointing to /auth/restricted/?mode=... for iframe-based authentication.
|
||||
"""
|
||||
try:
|
||||
ctx = await authz.verify(auth, perm)
|
||||
ctx = await authz.verify(
|
||||
auth, perm, host=request.headers.get("host"), max_age=max_age
|
||||
)
|
||||
role_permissions = set(ctx.role.permissions or [])
|
||||
if ctx.permissions:
|
||||
role_permissions.update(permission.id for permission in ctx.permissions)
|
||||
@@ -107,171 +153,129 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None))
|
||||
"Remote-Org-Name": ctx.org.display_name,
|
||||
"Remote-Role": str(ctx.role.uuid),
|
||||
"Remote-Role-Name": ctx.role.display_name,
|
||||
"Remote-Session-Expires": ctx.session.expires.isoformat(),
|
||||
"Remote-Session-Expires": (
|
||||
session_expiry(ctx.session)
|
||||
.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if session_expiry(ctx.session).tzinfo
|
||||
else session_expiry(ctx.session)
|
||||
.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
"Remote-Credential": str(ctx.session.credential_uuid),
|
||||
}
|
||||
return Response(status_code=204, headers=remote_headers)
|
||||
except HTTPException as e:
|
||||
return FileResponse(frontend.file("index.html"), status_code=e.status_code)
|
||||
except authz.AuthException as e:
|
||||
# Clear cookie only if session is invalid (not for reauth)
|
||||
if e.clear_session:
|
||||
session.clear_session_cookie(response)
|
||||
|
||||
# Check Accept header to decide response format
|
||||
accept = request.headers.get("accept", "")
|
||||
wants_html = "text/html" in accept
|
||||
|
||||
if wants_html:
|
||||
# Browser request - return full-page HTML with metadata
|
||||
data_attrs = {"mode": e.mode, **e.metadata}
|
||||
html = (await frontend.read("/int/forward/index.html"))[0]
|
||||
html = htmlutil.patch_html_data_attrs(html, **data_attrs)
|
||||
return Response(
|
||||
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
|
||||
)
|
||||
else:
|
||||
# API request - return JSON with iframe srcdoc HTML
|
||||
return JSONResponse(
|
||||
status_code=e.status_code,
|
||||
content=await authz.auth_error_content(e),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/settings")
|
||||
async def get_settings():
|
||||
pk = global_passkey.instance
|
||||
return {"rp_id": pk.rp_id, "rp_name": pk.rp_name}
|
||||
base_path = hostutil.ui_base_path()
|
||||
return {
|
||||
"rp_id": pk.rp_id,
|
||||
"rp_name": pk.rp_name,
|
||||
"ui_base_path": base_path,
|
||||
"auth_host": hostutil.configured_auth_host(),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/user-info")
|
||||
async def api_user_info(reset: str | None = None, auth=Cookie(None)):
|
||||
async def api_user_info(
|
||||
request: Request,
|
||||
response: Response,
|
||||
reset: str | None = None,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Get user information including credentials, sessions, and permissions.
|
||||
|
||||
Can be called with either:
|
||||
- A session cookie (auth) for authenticated users
|
||||
- A reset token for users in password reset flow
|
||||
"""
|
||||
authenticated = False
|
||||
session_record = None
|
||||
reset_token = None
|
||||
try:
|
||||
if reset:
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise ValueError("Invalid reset token")
|
||||
s = await get_reset(reset)
|
||||
reset_token = await get_reset(reset)
|
||||
target_user_uuid = reset_token.user_uuid
|
||||
else:
|
||||
if auth is None:
|
||||
raise ValueError("Authentication Required")
|
||||
s = await get_session(auth)
|
||||
raise authz.AuthException(
|
||||
status_code=401,
|
||||
detail="Authentication required",
|
||||
mode="login",
|
||||
)
|
||||
session_record = await get_session(auth, host=request.headers.get("host"))
|
||||
authenticated = True
|
||||
target_user_uuid = session_record.user_uuid
|
||||
except ValueError as e:
|
||||
raise HTTPException(401, str(e))
|
||||
|
||||
u = await db.instance.get_user_by_uuid(s.user_uuid)
|
||||
# Return minimal response for reset tokens
|
||||
if not authenticated and reset_token:
|
||||
return await userinfo.format_reset_user_info(target_user_uuid, reset_token)
|
||||
|
||||
if not authenticated: # minimal response for reset tokens
|
||||
return {
|
||||
"authenticated": False,
|
||||
"session_type": s.info.get("type"),
|
||||
"user": {"user_uuid": str(u.uuid), "user_name": u.display_name},
|
||||
}
|
||||
# Return full user info for authenticated users
|
||||
assert auth is not None
|
||||
assert session_record is not None
|
||||
|
||||
assert authenticated and auth is not None
|
||||
|
||||
ctx = await permutil.session_context(auth)
|
||||
credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid)
|
||||
credentials: list[dict] = []
|
||||
user_aaguids: set[str] = set()
|
||||
for cred_id in credential_ids:
|
||||
try:
|
||||
c = await db.instance.get_credential_by_id(cred_id)
|
||||
except ValueError:
|
||||
continue
|
||||
aaguid_str = str(c.aaguid)
|
||||
user_aaguids.add(aaguid_str)
|
||||
credentials.append(
|
||||
{
|
||||
"credential_uuid": str(c.uuid),
|
||||
"aaguid": aaguid_str,
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"last_used": c.last_used.isoformat() if c.last_used else None,
|
||||
"last_verified": c.last_verified.isoformat()
|
||||
if c.last_verified
|
||||
else None,
|
||||
"sign_count": c.sign_count,
|
||||
"is_current_session": s.credential_uuid == c.uuid,
|
||||
}
|
||||
)
|
||||
credentials.sort(key=lambda cred: cred["created_at"])
|
||||
aaguid_info = aaguid.filter(user_aaguids)
|
||||
|
||||
role_info = None
|
||||
org_info = None
|
||||
effective_permissions: list[str] = []
|
||||
is_global_admin = False
|
||||
is_org_admin = False
|
||||
if ctx:
|
||||
role_info = {
|
||||
"uuid": str(ctx.role.uuid),
|
||||
"display_name": ctx.role.display_name,
|
||||
"permissions": ctx.role.permissions,
|
||||
}
|
||||
org_info = {
|
||||
"uuid": str(ctx.org.uuid),
|
||||
"display_name": ctx.org.display_name,
|
||||
"permissions": ctx.org.permissions,
|
||||
}
|
||||
effective_permissions = [p.id for p in (ctx.permissions or [])]
|
||||
is_global_admin = "auth:admin" in (role_info["permissions"] or [])
|
||||
if org_info:
|
||||
is_org_admin = f"auth:org:{org_info['uuid']}" in (
|
||||
role_info["permissions"] or []
|
||||
)
|
||||
|
||||
return {
|
||||
"authenticated": True,
|
||||
"session_type": s.info.get("type"),
|
||||
"user": {
|
||||
"user_uuid": str(u.uuid),
|
||||
"user_name": u.display_name,
|
||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||
"last_seen": u.last_seen.isoformat() if u.last_seen else None,
|
||||
"visits": u.visits,
|
||||
},
|
||||
"org": org_info,
|
||||
"role": role_info,
|
||||
"permissions": effective_permissions,
|
||||
"is_global_admin": is_global_admin,
|
||||
"is_org_admin": is_org_admin,
|
||||
"credentials": credentials,
|
||||
"aaguid_info": aaguid_info,
|
||||
}
|
||||
|
||||
|
||||
@app.put("/user/display-name")
|
||||
async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)):
|
||||
if not auth:
|
||||
raise HTTPException(status_code=401, detail="Authentication Required")
|
||||
s = await get_session(auth)
|
||||
new_name = (payload.get("display_name") or "").strip()
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=400, detail="display_name required")
|
||||
if len(new_name) > 64:
|
||||
raise HTTPException(status_code=400, detail="display_name too long")
|
||||
await db.instance.update_user_display_name(s.user_uuid, new_name)
|
||||
return {"status": "ok"}
|
||||
return await userinfo.format_user_info(
|
||||
user_uuid=target_user_uuid,
|
||||
auth=auth,
|
||||
session_record=session_record,
|
||||
request_host=request.headers.get("host"),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
async def api_logout(response: Response, auth=Cookie(None)):
|
||||
async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
if not auth:
|
||||
return {"message": "Already logged out"}
|
||||
try:
|
||||
await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError:
|
||||
return {"message": "Already logged out"}
|
||||
with suppress(Exception):
|
||||
await db.instance.delete_session(session_key(auth))
|
||||
response.delete_cookie("auth")
|
||||
session.clear_session_cookie(response)
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
|
||||
@app.post("/set-session")
|
||||
async def api_set_session(response: Response, auth=Depends(bearer_auth)):
|
||||
user = await get_session(auth.credentials)
|
||||
async def api_set_session(
|
||||
request: Request, response: Response, auth=Depends(bearer_auth)
|
||||
):
|
||||
user = await get_session(auth.credentials, host=request.headers.get("host"))
|
||||
session.set_session_cookie(response, auth.credentials)
|
||||
return {
|
||||
"message": "Session cookie set successfully",
|
||||
"user_uuid": str(user.user_uuid),
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/credential/{uuid}")
|
||||
async def api_delete_credential(uuid: UUID, auth: str = Cookie(None)):
|
||||
await delete_credential(uuid, auth)
|
||||
return {"message": "Credential deleted successfully"}
|
||||
|
||||
|
||||
@app.post("/create-link")
|
||||
async def api_create_link(request: Request, auth=Cookie(None)):
|
||||
s = await get_session(auth)
|
||||
token = passphrase.generate()
|
||||
await db.instance.create_session(
|
||||
user_uuid=s.user_uuid,
|
||||
key=tokens.reset_key(token),
|
||||
expires=expires(),
|
||||
info=session.infodict(request, "device addition"),
|
||||
)
|
||||
origin = global_passkey.instance.origin.rstrip("/")
|
||||
url = f"{origin}/auth/{token}"
|
||||
return {
|
||||
"message": "Registration link generated successfully",
|
||||
"url": url,
|
||||
"expires": expires().isoformat(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Middleware for handling auth host redirects."""
|
||||
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from passkey.util import hostutil, passphrase
|
||||
|
||||
|
||||
def is_ui_path(path: str) -> bool:
|
||||
"""Check if the path is a UI endpoint."""
|
||||
ui_paths = {
|
||||
"/",
|
||||
"/admin",
|
||||
"/admin/",
|
||||
"/auth",
|
||||
"/auth/",
|
||||
"/auth/admin",
|
||||
"/auth/admin/",
|
||||
}
|
||||
if path in ui_paths:
|
||||
return True
|
||||
# Treat reset token pages as UI (dynamic). Accept single-segment tokens.
|
||||
if path.startswith("/auth/"):
|
||||
token = path[6:]
|
||||
if token and "/" not in token and passphrase.is_well_formed(token):
|
||||
return True
|
||||
else:
|
||||
token = path[1:]
|
||||
if token and "/" not in token and passphrase.is_well_formed(token):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_restricted_path(path: str) -> bool:
|
||||
"""Check if the path is restricted (API/admin endpoints)."""
|
||||
return path.startswith(("/auth/api/admin/", "/auth/api/user/", "/auth/ws/"))
|
||||
|
||||
|
||||
def should_redirect_to_auth_host(path: str) -> bool:
|
||||
"""Determine if the request should be redirected to the auth host."""
|
||||
if path in {"/", "/auth", "/auth/"}:
|
||||
return False
|
||||
return is_ui_path(path) or is_restricted_path(path)
|
||||
|
||||
|
||||
def redirect_to_auth_host(request: Request, cfg: str, path: str) -> Response:
|
||||
"""Create a redirect response to the auth host."""
|
||||
if is_restricted_path(path):
|
||||
return Response(status_code=404)
|
||||
new_path = (
|
||||
path[5:] or "/" if is_ui_path(path) and path.startswith("/auth") else path
|
||||
)
|
||||
return RedirectResponse(f"{request.url.scheme}://{cfg}{new_path}", 307)
|
||||
|
||||
|
||||
def should_redirect_auth_path_to_root(path: str) -> bool:
|
||||
"""Check if /auth/ UI path should be redirected to root on auth host."""
|
||||
if not path.startswith("/auth/"):
|
||||
return False
|
||||
ui_paths = {"/auth", "/auth/", "/auth/admin", "/auth/admin/"}
|
||||
if path in ui_paths:
|
||||
return True
|
||||
# Check for reset token
|
||||
token = path[6:]
|
||||
return bool(token and "/" not in token and passphrase.is_well_formed(token))
|
||||
|
||||
|
||||
def redirect_to_root_on_auth_host(request: Request, cur: str, path: str) -> Response:
|
||||
"""Create a redirect response to root path on the same host."""
|
||||
new_path = path[5:] or "/"
|
||||
return RedirectResponse(f"{request.url.scheme}://{cur}{new_path}", 307)
|
||||
|
||||
|
||||
async def redirect_middleware(request: Request, call_next):
|
||||
"""Middleware to handle auth host redirects."""
|
||||
cfg = hostutil.configured_auth_host()
|
||||
if not cfg:
|
||||
return await call_next(request)
|
||||
|
||||
cur = hostutil.normalize_host(request.headers.get("host"))
|
||||
if not cur:
|
||||
return await call_next(request)
|
||||
|
||||
cfg_normalized = hostutil.normalize_host(cfg)
|
||||
on_auth_host = cur == cfg_normalized
|
||||
|
||||
path = request.url.path or "/"
|
||||
|
||||
if not on_auth_host:
|
||||
if not should_redirect_to_auth_host(path):
|
||||
return await call_next(request)
|
||||
return redirect_to_auth_host(request, cfg, path)
|
||||
else:
|
||||
# On auth host: force UI endpoints at root
|
||||
if should_redirect_auth_path_to_root(path):
|
||||
return redirect_to_root_on_auth_host(request, cur, path)
|
||||
return await call_next(request)
|
||||
@@ -2,26 +2,95 @@ import logging
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..util import permutil
|
||||
from ..util import permutil, sessionutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def verify(auth: str | None, perm: list[str], match=permutil.has_all):
|
||||
class AuthException(HTTPException):
|
||||
"""Exception raised during authentication/authorization with metadata for the UI.
|
||||
|
||||
Attributes:
|
||||
status_code: HTTP status code (401 for auth, 403 for authz)
|
||||
detail: Error message
|
||||
mode: UI mode ('login' or 'reauth')
|
||||
clear_session: Whether to clear the session cookie (True for invalid sessions)
|
||||
metadata: Additional data to pass to the frontend
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
detail: str,
|
||||
mode: str,
|
||||
clear_session: bool = False,
|
||||
**metadata,
|
||||
):
|
||||
super().__init__(status_code=status_code, detail=detail)
|
||||
self.mode = mode
|
||||
self.clear_session = clear_session
|
||||
self.metadata = metadata
|
||||
|
||||
|
||||
async def auth_error_content(exc: AuthException) -> dict:
|
||||
"""Generate JSON response content for an AuthException.
|
||||
|
||||
Returns a dict with detail, mode, and iframe URL for src embedding.
|
||||
"""
|
||||
# Build hash fragment from mode and metadata
|
||||
params = {"mode": exc.mode, **exc.metadata}
|
||||
fragment = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
|
||||
iframe_url = f"/auth/restricted/#{fragment}"
|
||||
return {
|
||||
"detail": exc.detail,
|
||||
"auth": {
|
||||
"mode": exc.mode,
|
||||
"iframe": iframe_url,
|
||||
**exc.metadata,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def verify(
|
||||
auth: str | None,
|
||||
perm: list[str],
|
||||
match=permutil.has_all,
|
||||
host: str | None = None,
|
||||
max_age: str | None = None,
|
||||
):
|
||||
"""Validate session token and optional list of required permissions.
|
||||
|
||||
Returns the session context.
|
||||
|
||||
Raises HTTPException on failure:
|
||||
401: unauthenticated / invalid session
|
||||
403: required permissions missing
|
||||
Raises AuthException on failure with metadata for UI rendering.
|
||||
"""
|
||||
if not auth:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
raise AuthException(
|
||||
status_code=401,
|
||||
detail="Authentication required",
|
||||
mode="login",
|
||||
)
|
||||
|
||||
ctx = await permutil.session_context(auth)
|
||||
ctx = await permutil.session_context(auth, host)
|
||||
if not ctx:
|
||||
raise HTTPException(status_code=401, detail="Session not found")
|
||||
raise AuthException(
|
||||
status_code=401,
|
||||
detail="Your session has expired. Please sign in again.",
|
||||
mode="login",
|
||||
clear_session=True,
|
||||
)
|
||||
# Check max_age requirement if specified
|
||||
if max_age:
|
||||
try:
|
||||
if not sessionutil.check_session_age(ctx, max_age):
|
||||
raise AuthException(
|
||||
status_code=401,
|
||||
detail="Additional authentication required",
|
||||
mode="reauth",
|
||||
)
|
||||
except ValueError as e:
|
||||
# Invalid max_age format - log but don't fail the request
|
||||
logger.warning(f"Invalid max_age format '{max_age}': {e}")
|
||||
|
||||
if not match(ctx, perm):
|
||||
# Determine which permissions are missing for clearer diagnostics
|
||||
@@ -34,6 +103,8 @@ async def verify(auth: str | None, perm: list[str], match=permutil.has_all):
|
||||
perm,
|
||||
ctx.role.permissions,
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="Permission required")
|
||||
raise AuthException(
|
||||
status_code=403, mode="forbidden", detail="Permission required"
|
||||
)
|
||||
|
||||
return ctx
|
||||
|
||||
+53
-23
@@ -2,13 +2,14 @@ import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from passkey.util import frontend, passphrase
|
||||
from passkey.util import frontend, hostutil, passphrase
|
||||
|
||||
from . import admin, api, ws
|
||||
from . import admin, api, auth_host, ws
|
||||
from .session import AUTH_COOKIE
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -41,38 +42,67 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
logging.error(f"⚠️ {e}")
|
||||
# Re-raise to fail fast
|
||||
raise
|
||||
|
||||
# In dev mode, Vite serves assets directly; in production, mount static files
|
||||
# This is deferred to lifespan because PASSKEY_DEVMODE is set after module import
|
||||
if not frontend.is_dev_mode():
|
||||
app.mount(
|
||||
"/auth/assets/",
|
||||
StaticFiles(directory=frontend.file("auth", "assets")),
|
||||
name="assets",
|
||||
)
|
||||
|
||||
yield
|
||||
# (Optional) add shutdown cleanup here later
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
app.mount("/auth/admin/", admin.app)
|
||||
|
||||
# Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
|
||||
app.middleware("http")(auth_host.redirect_middleware)
|
||||
|
||||
app.mount("/auth/api/admin/", admin.app)
|
||||
app.mount("/auth/api/", api.app)
|
||||
app.mount("/auth/ws/", ws.app)
|
||||
app.mount(
|
||||
"/auth/assets/", StaticFiles(directory=frontend.file("assets")), name="assets"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/auth/restricted/")
|
||||
async def restricted_view():
|
||||
"""Serve the restricted/authentication UI for iframe embedding."""
|
||||
return Response(*await frontend.read("/auth/restricted/index.html"))
|
||||
|
||||
|
||||
# Navigable URLs are defined here. We support both / and /auth/ as the base path
|
||||
# / is used on a dedicated auth site, /auth/ on app domains with auth
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def frontapp_redirect(request: Request):
|
||||
"""Redirect root (in case accessed on backend) to the main authentication app."""
|
||||
return RedirectResponse(request.url_for("frontapp"), status_code=303)
|
||||
|
||||
|
||||
@app.get("/auth/")
|
||||
async def frontapp():
|
||||
"""Serve the main authentication app."""
|
||||
return FileResponse(frontend.file("index.html"))
|
||||
async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
"""Serve the user profile app.
|
||||
|
||||
The frontend handles mode detection (host mode vs full profile) based on settings.
|
||||
Access control is handled via APIs.
|
||||
"""
|
||||
return Response(*await frontend.read("/auth/index.html"))
|
||||
|
||||
|
||||
@app.get("/admin", include_in_schema=False)
|
||||
@app.get("/auth/admin", include_in_schema=False)
|
||||
async def admin_root_redirect():
|
||||
return RedirectResponse(f"{hostutil.ui_base_path()}admin/", status_code=307)
|
||||
|
||||
|
||||
@app.get("/admin/", include_in_schema=False)
|
||||
async def admin_root(request: Request, auth=AUTH_COOKIE):
|
||||
return await admin.adminapp(request, auth) # Delegated to admin app
|
||||
|
||||
|
||||
# Note: this catch-all handler must be the last route defined
|
||||
@app.get("/{reset}")
|
||||
@app.get("/auth/{reset}")
|
||||
async def reset_link(request: Request, reset: str):
|
||||
"""Pretty URL for reset links."""
|
||||
if reset == "admin":
|
||||
# Admin app missing trailing slash lands here, be friendly to user
|
||||
return RedirectResponse(request.url_for("adminapp"), status_code=303)
|
||||
async def reset_link(reset: str):
|
||||
"""Serve the reset app directly with an injected reset token."""
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise HTTPException(status_code=404)
|
||||
url = request.url_for("frontapp").include_query_params(reset=reset)
|
||||
return RedirectResponse(url, status_code=303)
|
||||
return Response(*await frontend.read("/int/reset/index.html"))
|
||||
|
||||
@@ -17,7 +17,7 @@ from uuid import UUID
|
||||
|
||||
from passkey import authsession as _authsession
|
||||
from passkey import globals as _g
|
||||
from passkey.util import passphrase
|
||||
from passkey.util import hostutil, passphrase
|
||||
from passkey.util import tokens as _tokens
|
||||
|
||||
|
||||
@@ -63,13 +63,14 @@ async def _resolve_targets(query: str | None):
|
||||
|
||||
async def _create_reset(user, role_name: str):
|
||||
token = passphrase.generate()
|
||||
await _g.db.instance.create_session(
|
||||
expiry = _authsession.reset_expires()
|
||||
await _g.db.instance.create_reset_token(
|
||||
user_uuid=user.uuid,
|
||||
key=_tokens.reset_key(token),
|
||||
expires=_authsession.expires(),
|
||||
info={"type": "manual reset", "role": role_name},
|
||||
expiry=expiry,
|
||||
token_type="manual reset",
|
||||
)
|
||||
return f"{_g.passkey.instance.origin}/auth/{token}", token
|
||||
return hostutil.reset_link_url(token), token
|
||||
|
||||
|
||||
async def _main(query: str | None) -> int:
|
||||
|
||||
@@ -8,26 +8,45 @@ This module provides FastAPI-specific session management functionality:
|
||||
Generic session management functions have been moved to authsession.py
|
||||
"""
|
||||
|
||||
from fastapi import Request, Response, WebSocket
|
||||
from fastapi import Cookie, Request, Response, WebSocket
|
||||
|
||||
from ..authsession import EXPIRES
|
||||
|
||||
AUTH_COOKIE_NAME = "__Host-auth"
|
||||
AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME)
|
||||
|
||||
|
||||
def infodict(request: Request | WebSocket, type: str) -> dict:
|
||||
"""Extract client information from request."""
|
||||
return {
|
||||
"ip": request.client.host if request.client else "",
|
||||
"user_agent": request.headers.get("user-agent", "")[:500],
|
||||
"type": type,
|
||||
"ip": request.client.host if request.client else None,
|
||||
"user_agent": request.headers.get("user-agent", "")[:500] or None,
|
||||
"session_type": type,
|
||||
}
|
||||
|
||||
|
||||
def set_session_cookie(response: Response, token: str) -> None:
|
||||
"""Set the session token as an HTTP-only cookie."""
|
||||
response.set_cookie(
|
||||
key="auth",
|
||||
key=AUTH_COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=int(EXPIRES.total_seconds()),
|
||||
httponly=True,
|
||||
secure=True,
|
||||
path="/",
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookie(response: Response) -> None:
|
||||
# FastAPI's delete_cookie does not set the secure attribute
|
||||
response.set_cookie(
|
||||
key=AUTH_COOKIE_NAME,
|
||||
value="",
|
||||
max_age=0,
|
||||
expires=0,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
path="/",
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
from datetime import timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import (
|
||||
Body,
|
||||
FastAPI,
|
||||
HTTPException,
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ..authsession import (
|
||||
delete_credential,
|
||||
expires,
|
||||
get_session,
|
||||
)
|
||||
from ..globals import db
|
||||
from ..util import hostutil, passphrase, tokens
|
||||
from ..util.tokens import decode_session_key, session_key
|
||||
from . import authz, session
|
||||
from .session import AUTH_COOKIE
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.exception_handler(authz.AuthException)
|
||||
async def auth_exception_handler(_request, exc: authz.AuthException):
|
||||
"""Handle AuthException with auth info for UI."""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=await authz.auth_error_content(exc),
|
||||
)
|
||||
|
||||
|
||||
@app.put("/display-name")
|
||||
async def user_update_display_name(
|
||||
request: Request,
|
||||
response: Response,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
if not auth:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
)
|
||||
try:
|
||||
s = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError as e:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
) from e
|
||||
new_name = (payload.get("display_name") or "").strip()
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=400, detail="display_name required")
|
||||
if len(new_name) > 64:
|
||||
raise HTTPException(status_code=400, detail="display_name too long")
|
||||
await db.instance.update_user_display_name(s.user_uuid, new_name)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/logout-all")
|
||||
async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
if not auth:
|
||||
return {"message": "Already logged out"}
|
||||
try:
|
||||
s = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
)
|
||||
await db.instance.delete_sessions_for_user(s.user_uuid)
|
||||
session.clear_session_cookie(response)
|
||||
return {"message": "Logged out from all hosts"}
|
||||
|
||||
|
||||
@app.delete("/session/{session_id}")
|
||||
async def api_delete_session(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session_id: str,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
if not auth:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
)
|
||||
try:
|
||||
current_session = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError as exc:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
target_key = decode_session_key(session_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid session identifier"
|
||||
) from exc
|
||||
|
||||
target_session = await db.instance.get_session(target_key)
|
||||
if not target_session or target_session.user_uuid != current_session.user_uuid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
await db.instance.delete_session(target_key)
|
||||
current_terminated = target_key == session_key(auth)
|
||||
if current_terminated:
|
||||
session.clear_session_cookie(response) # explicit because 200
|
||||
return {"status": "ok", "current_session_terminated": current_terminated}
|
||||
|
||||
|
||||
@app.delete("/credential/{uuid}")
|
||||
async def api_delete_credential(
|
||||
request: Request,
|
||||
response: Response,
|
||||
uuid: UUID,
|
||||
auth: str = AUTH_COOKIE,
|
||||
):
|
||||
# Require recent authentication for sensitive operation
|
||||
await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
|
||||
try:
|
||||
await delete_credential(uuid, auth, host=request.headers.get("host"))
|
||||
except ValueError as e:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
) from e
|
||||
return {"message": "Credential deleted successfully"}
|
||||
|
||||
|
||||
@app.post("/create-link")
|
||||
async def api_create_link(
|
||||
request: Request,
|
||||
response: Response,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
# Require recent authentication for sensitive operation
|
||||
await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
|
||||
try:
|
||||
s = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError as e:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
) from e
|
||||
token = passphrase.generate()
|
||||
expiry = expires()
|
||||
await db.instance.create_reset_token(
|
||||
user_uuid=s.user_uuid,
|
||||
key=tokens.reset_key(token),
|
||||
expiry=expiry,
|
||||
token_type="device addition",
|
||||
)
|
||||
url = hostutil.reset_link_url(
|
||||
token, request.url.scheme, request.headers.get("host")
|
||||
)
|
||||
return {
|
||||
"message": "Registration link generated successfully",
|
||||
"url": url,
|
||||
"expires": (
|
||||
expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if expiry.tzinfo
|
||||
else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
),
|
||||
}
|
||||
+67
-20
@@ -2,14 +2,15 @@ import logging
|
||||
from functools import wraps
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Cookie, FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
||||
|
||||
from ..authsession import create_session, expires, get_reset, get_session
|
||||
from ..authsession import create_session, get_reset, get_session
|
||||
from ..globals import db, passkey
|
||||
from ..util import passphrase
|
||||
from ..util.tokens import create_token, session_key
|
||||
from .session import infodict
|
||||
from . import authz
|
||||
from .session import AUTH_COOKIE, infodict
|
||||
|
||||
|
||||
# WebSocket error handling decorator
|
||||
@@ -21,11 +22,18 @@ def websocket_error_handler(func):
|
||||
return await func(ws, *args, **kwargs)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except authz.AuthException as e:
|
||||
await ws.send_json(
|
||||
{
|
||||
"status": e.status_code,
|
||||
**(await authz.auth_error_content(e)),
|
||||
}
|
||||
)
|
||||
except (ValueError, InvalidAuthenticationResponse) as e:
|
||||
await ws.send_json({"detail": str(e)})
|
||||
await ws.send_json({"status": 401, "detail": str(e)})
|
||||
except Exception:
|
||||
logging.exception("Internal Server Error")
|
||||
await ws.send_json({"detail": "Internal Server Error"})
|
||||
await ws.send_json({"status": 500, "detail": "Internal Server Error"})
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -48,7 +56,7 @@ async def register_chat(
|
||||
credential_ids=credential_ids,
|
||||
origin=origin,
|
||||
)
|
||||
await ws.send_json(options)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
response = await ws.receive_json()
|
||||
return passkey.instance.reg_verify(response, challenge, user_uuid, origin=origin)
|
||||
|
||||
@@ -56,24 +64,31 @@ async def register_chat(
|
||||
@app.websocket("/register")
|
||||
@websocket_error_handler
|
||||
async def websocket_register_add(
|
||||
ws: WebSocket, reset: str | None = None, name: str | None = None, auth=Cookie(None)
|
||||
ws: WebSocket,
|
||||
reset: str | None = None,
|
||||
name: str | None = None,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Register a new credential for an existing user.
|
||||
|
||||
Supports either:
|
||||
- Normal session via auth cookie
|
||||
- Normal session via auth cookie (requires recent authentication)
|
||||
- Reset token supplied as ?reset=... (auth cookie ignored)
|
||||
"""
|
||||
origin = ws.headers["origin"]
|
||||
host = origin.split("://", 1)[1]
|
||||
if reset is not None:
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise ValueError("Invalid reset token")
|
||||
raise ValueError(
|
||||
f"The reset link for {passkey.instance.rp_name} is invalid or has expired"
|
||||
)
|
||||
s = await get_reset(reset)
|
||||
user_uuid = s.user_uuid
|
||||
else:
|
||||
if not auth:
|
||||
raise ValueError("Authentication Required")
|
||||
s = await get_session(auth)
|
||||
user_uuid = s.user_uuid
|
||||
# Require recent authentication for adding a new passkey
|
||||
ctx = await authz.verify(auth, perm=[], host=host, max_age="5m")
|
||||
user_uuid = ctx.session.user_uuid
|
||||
s = ctx.session
|
||||
|
||||
# Get user information and determine effective user_name for this registration
|
||||
user = await db.instance.get_user_by_uuid(user_uuid)
|
||||
@@ -89,14 +104,16 @@ async def websocket_register_add(
|
||||
|
||||
# Create a new session and store everything in database
|
||||
token = create_token()
|
||||
metadata = infodict(ws, "authenticated")
|
||||
await db.instance.create_credential_session( # type: ignore[attr-defined]
|
||||
user_uuid=user_uuid,
|
||||
credential=credential,
|
||||
reset_key=(s.key if reset is not None else None),
|
||||
session_key=session_key(token),
|
||||
session_expires=expires(),
|
||||
session_info=infodict(ws, "authenticated"),
|
||||
display_name=user_name,
|
||||
host=host,
|
||||
ip=metadata.get("ip"),
|
||||
user_agent=metadata.get("user_agent"),
|
||||
)
|
||||
auth = token
|
||||
|
||||
@@ -113,14 +130,41 @@ async def websocket_register_add(
|
||||
|
||||
@app.websocket("/authenticate")
|
||||
@websocket_error_handler
|
||||
async def websocket_authenticate(ws: WebSocket):
|
||||
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
origin = ws.headers["origin"]
|
||||
options, challenge = passkey.instance.auth_generate_options()
|
||||
await ws.send_json(options)
|
||||
host = origin.split("://", 1)[1]
|
||||
|
||||
# If there's an existing session, restrict to that user's credentials (reauth)
|
||||
session_user_uuid = None
|
||||
credential_ids = None
|
||||
if auth:
|
||||
try:
|
||||
session = await get_session(auth, host=host)
|
||||
session_user_uuid = session.user_uuid
|
||||
credential_ids = await db.instance.get_credentials_by_user_uuid(
|
||||
session_user_uuid
|
||||
)
|
||||
except ValueError:
|
||||
pass # Invalid/expired session - allow normal authentication
|
||||
|
||||
options, challenge = passkey.instance.auth_generate_options(
|
||||
credential_ids=credential_ids
|
||||
)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
# Wait for the client to use his authenticator to authenticate
|
||||
credential = passkey.instance.auth_parse(await ws.receive_json())
|
||||
# Fetch from the database by credential ID
|
||||
stored_cred = await db.instance.get_credential_by_id(credential.raw_id)
|
||||
try:
|
||||
stored_cred = await db.instance.get_credential_by_id(credential.raw_id)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"This passkey is no longer registered with {passkey.instance.rp_name}"
|
||||
)
|
||||
|
||||
# If reauth mode, verify the credential belongs to the session's user
|
||||
if session_user_uuid and stored_cred.user_uuid != session_user_uuid:
|
||||
raise ValueError("This passkey belongs to a different account")
|
||||
|
||||
# Verify the credential matches the stored data
|
||||
passkey.instance.auth_verify(credential, challenge, stored_cred, origin=origin)
|
||||
# Update both credential and user's last_seen timestamp
|
||||
@@ -128,10 +172,13 @@ async def websocket_authenticate(ws: WebSocket):
|
||||
|
||||
# Create a session token for the authenticated user
|
||||
assert stored_cred.uuid is not None
|
||||
metadata = infodict(ws, "auth")
|
||||
token = await create_session(
|
||||
user_uuid=stored_cred.user_uuid,
|
||||
info=infodict(ws, "auth"),
|
||||
credential_uuid=stored_cred.uuid,
|
||||
host=host,
|
||||
ip=metadata.get("ip") or "",
|
||||
user_agent=metadata.get("user_agent") or "",
|
||||
)
|
||||
|
||||
await ws.send_json(
|
||||
|
||||
+4
-4
@@ -8,7 +8,7 @@ This module provides a unified interface for WebAuthn operations including:
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
|
||||
@@ -163,7 +163,7 @@ class Passkey:
|
||||
aaguid=UUID(registration.aaguid),
|
||||
public_key=registration.credential_public_key,
|
||||
sign_count=registration.sign_count,
|
||||
created_at=datetime.now(),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
### Authentication Methods ###
|
||||
@@ -184,7 +184,7 @@ class Passkey:
|
||||
authopts: Additional arguments to generate_authentication_options.
|
||||
|
||||
Returns:
|
||||
Tuple of (JSON to be sent to client, challenge bytes to store)
|
||||
Tuple of (JSON dict to be sent to client, challenge bytes to store)
|
||||
"""
|
||||
options = generate_authentication_options(
|
||||
rp_id=self.rp_id,
|
||||
@@ -227,7 +227,7 @@ class Passkey:
|
||||
credential_current_sign_count=stored_cred.sign_count,
|
||||
)
|
||||
stored_cred.sign_count = verification.new_sign_count
|
||||
now = datetime.now()
|
||||
now = datetime.now(timezone.utc)
|
||||
stored_cred.last_used = now
|
||||
if verification.user_verified:
|
||||
stored_cred.last_verified = now
|
||||
|
||||
+116
-22
@@ -1,7 +1,42 @@
|
||||
import asyncio
|
||||
import atexit
|
||||
import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
from threading import Thread
|
||||
|
||||
__all__ = ["path", "file", "run_dev"]
|
||||
import httpx
|
||||
|
||||
__all__ = ["path", "file", "read", "run_dev", "is_dev_mode"]
|
||||
|
||||
DEV_SERVER = "http://localhost:4403"
|
||||
|
||||
NO_FRONTEND_TOOL = """\
|
||||
┃ ⚠️ deno, npm or bunx needed to run the frontend server.
|
||||
"""
|
||||
|
||||
BUN_BUG = """\
|
||||
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
|
||||
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
|
||||
┃
|
||||
┃ Options:
|
||||
┃ - sudo caddy run --config caddy/Caddyfile.dev
|
||||
┃ - Install deno or npm instead
|
||||
┃
|
||||
┃ Caddy will skip the Vite for API calls and serve everything at port 443.
|
||||
┃ Otherwise Vite serves at port 8077 and proxies to backend (broken with bun).
|
||||
"""
|
||||
|
||||
NO_FRONTEND = """\
|
||||
┃
|
||||
┃ Note: only static build of the frontend is served at localhost:4402.
|
||||
┃ The page will not update with frontend code changes.
|
||||
"""
|
||||
|
||||
|
||||
def _resolve_static_dir() -> Path:
|
||||
@@ -25,13 +60,47 @@ def file(*parts: str) -> Path:
|
||||
return path.joinpath(*parts)
|
||||
|
||||
|
||||
def run_dev():
|
||||
"""Spawn the frontend dev server (bun or npm) as a background process."""
|
||||
import atexit
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
def is_dev_mode() -> bool:
|
||||
"""Check if we're running in dev mode (Vite frontend server)."""
|
||||
return os.environ.get("PASSKEY_DEVMODE") == "1"
|
||||
|
||||
|
||||
async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
|
||||
"""Read file content and return response tuple.
|
||||
|
||||
In dev mode, fetches from the Vite dev server.
|
||||
In production, reads from the static build directory.
|
||||
|
||||
Args:
|
||||
filepath: Path relative to frontend root, e.g. "/auth/index.html"
|
||||
|
||||
Returns:
|
||||
Tuple of (content, status_code, headers) suitable for
|
||||
FastAPI Response(*args) or Sanic raw response.
|
||||
"""
|
||||
if is_dev_mode():
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(f"{DEV_SERVER}{filepath}")
|
||||
resp.raise_for_status()
|
||||
mime = resp.headers.get("content-type", "application/octet-stream")
|
||||
# Strip charset suffix if present
|
||||
mime = mime.split(";")[0].strip()
|
||||
return resp.content, resp.status_code, {"content-type": mime}
|
||||
else:
|
||||
# Production: read from static build
|
||||
file_path = path / filepath.lstrip("/")
|
||||
content = await _read_file_async(file_path)
|
||||
mime, _ = mimetypes.guess_type(str(file_path))
|
||||
return content, 200, {"content-type": mime or "application/octet-stream"}
|
||||
|
||||
|
||||
async def _read_file_async(file_path: Path) -> bytes:
|
||||
"""Read file asynchronously using asyncio.to_thread."""
|
||||
return await asyncio.to_thread(file_path.read_bytes)
|
||||
|
||||
|
||||
def run_dev():
|
||||
"""Spawn the frontend dev server (deno, npm, or bunx) as a background process."""
|
||||
devpath = Path(__file__).parent.parent.parent / "frontend"
|
||||
if not (devpath / "package.json").exists():
|
||||
raise RuntimeError(
|
||||
@@ -39,22 +108,47 @@ def run_dev():
|
||||
if "site-packages" in devpath.parts
|
||||
else f"Frontend source code not found at {devpath}"
|
||||
)
|
||||
bun = shutil.which("bun")
|
||||
npm = shutil.which("npm") if bun is None else None
|
||||
if not bun and not npm:
|
||||
raise RuntimeError("Neither bun nor npm found on PATH for dev server")
|
||||
cmd: list[str] = [bun, "--bun", "run", "dev"] if bun else [npm, "run", "dev"] # type: ignore[list-item]
|
||||
proc = subprocess.Popen(cmd, cwd=str(devpath))
|
||||
|
||||
def _terminate():
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
options = [
|
||||
("deno", "run", "dev"),
|
||||
("npm", "run", "dev", "--"),
|
||||
("bunx", "--bun", "vite"),
|
||||
]
|
||||
cmd = None
|
||||
tool_name = None
|
||||
for option in options:
|
||||
if tool := shutil.which(option[0]):
|
||||
cmd = [tool, *option[1:]]
|
||||
tool_name = option[0]
|
||||
break
|
||||
|
||||
atexit.register(_terminate)
|
||||
vite_process = None
|
||||
|
||||
def _signal_handler(signum, frame):
|
||||
_terminate()
|
||||
raise SystemExit(0)
|
||||
def start_vite():
|
||||
nonlocal vite_process
|
||||
if cmd is None:
|
||||
stderr.write(NO_FRONTEND_TOOL)
|
||||
stderr.write(NO_FRONTEND)
|
||||
return
|
||||
assert tool_name is not None
|
||||
try:
|
||||
if tool_name == "bunx":
|
||||
stderr.write(BUN_BUG)
|
||||
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
signal.signal(sig, _signal_handler)
|
||||
stderr.write(f">>> {' '.join([tool_name, *cmd[1:]])}\n")
|
||||
vite_process = subprocess.Popen(cmd, cwd=str(devpath), shell=False)
|
||||
except Exception as e:
|
||||
stderr.write(f"┃ ⚠️ Vite couldn't start: {e}\n")
|
||||
stderr.write(NO_FRONTEND)
|
||||
|
||||
def cleanup():
|
||||
vite_process.terminate()
|
||||
vite_process.wait()
|
||||
|
||||
# Start Vite in a separate thread
|
||||
vite_thread = Thread(target=start_vite, daemon=True)
|
||||
vite_thread.start()
|
||||
|
||||
atexit.register(cleanup)
|
||||
signal.signal(signal.SIGTERM, lambda *_: cleanup())
|
||||
signal.signal(signal.SIGINT, lambda *_: cleanup())
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Utilities for determining the auth UI host and base URLs."""
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from urllib.parse import urlparse, urlsplit
|
||||
|
||||
from ..globals import passkey as global_passkey
|
||||
|
||||
_AUTH_HOST_ENV = "PASSKEY_AUTH_HOST"
|
||||
|
||||
|
||||
def _default_origin_scheme() -> str:
|
||||
origin_url = urlparse(global_passkey.instance.origin)
|
||||
return origin_url.scheme or "https"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_config() -> tuple[str | None, str] | None:
|
||||
raw = os.getenv(_AUTH_HOST_ENV)
|
||||
if not raw:
|
||||
return None
|
||||
candidate = raw.strip()
|
||||
if not candidate:
|
||||
return None
|
||||
parsed = urlparse(candidate if "://" in candidate else f"//{candidate}")
|
||||
netloc = parsed.netloc or parsed.path
|
||||
if not netloc:
|
||||
return None
|
||||
return (parsed.scheme or None, netloc.strip("/"))
|
||||
|
||||
|
||||
def configured_auth_host() -> str | None:
|
||||
cfg = _load_config()
|
||||
return cfg[1] if cfg else None
|
||||
|
||||
|
||||
def is_root_mode() -> bool:
|
||||
return _load_config() is not None
|
||||
|
||||
|
||||
def ui_base_path() -> str:
|
||||
return "/" if is_root_mode() else "/auth/"
|
||||
|
||||
|
||||
def auth_site_base_url(scheme: str | None = None, host: str | None = None) -> str:
|
||||
cfg = _load_config()
|
||||
if cfg:
|
||||
cfg_scheme, cfg_host = cfg
|
||||
scheme_to_use = cfg_scheme or scheme or _default_origin_scheme()
|
||||
netloc = cfg_host
|
||||
else:
|
||||
if host:
|
||||
scheme_to_use = scheme or _default_origin_scheme()
|
||||
netloc = host.strip("/")
|
||||
else:
|
||||
origin = global_passkey.instance.origin.rstrip("/")
|
||||
return f"{origin}{ui_base_path()}"
|
||||
|
||||
base = f"{scheme_to_use}://{netloc}".rstrip("/")
|
||||
path = ui_base_path().lstrip("/")
|
||||
return f"{base}/{path}" if path else f"{base}/"
|
||||
|
||||
|
||||
def reset_link_url(
|
||||
token: str, scheme: str | None = None, host: str | None = None
|
||||
) -> str:
|
||||
base = auth_site_base_url(scheme, host)
|
||||
return f"{base}{token}"
|
||||
|
||||
|
||||
def reload_config() -> None:
|
||||
_load_config.cache_clear()
|
||||
|
||||
|
||||
def normalize_host(raw_host: str | None) -> str | None:
|
||||
"""Normalize a Host header preserving port (exact match required)."""
|
||||
if not raw_host:
|
||||
return None
|
||||
candidate = raw_host.strip()
|
||||
if not candidate:
|
||||
return None
|
||||
# urlsplit to parse (add // for scheme-less); prefer netloc to retain port.
|
||||
parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}")
|
||||
netloc = parsed.netloc or parsed.path or ""
|
||||
# Strip IPv6 brackets around host part but retain port suffix.
|
||||
if netloc.startswith("["):
|
||||
# format: [ipv6]:port or [ipv6]
|
||||
if "]" in netloc:
|
||||
host_part, _, rest = netloc.partition("]")
|
||||
port_part = rest.lstrip(":")
|
||||
netloc = host_part.strip("[]") + (f":{port_part}" if port_part else "")
|
||||
return netloc.lower() or None
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Utility functions for HTML manipulation."""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def patch_html_data_attrs(html: bytes, **data_attrs: str) -> bytes:
|
||||
"""Patch HTML by adding data attributes to the <html> tag.
|
||||
|
||||
If an <html> tag exists, adds data attributes to it.
|
||||
If no <html> tag exists, prepends one with the data attributes.
|
||||
|
||||
Args:
|
||||
html: The HTML content as bytes
|
||||
**data_attrs: Key-value pairs for data attributes (e.g., mode='reauth')
|
||||
|
||||
Returns:
|
||||
Modified HTML as bytes
|
||||
|
||||
Examples:
|
||||
>>> patch_html_data_attrs(b'<html><body>test</body></html>', mode='reauth')
|
||||
b'<html data-mode="reauth"><body>test</body></html>'
|
||||
|
||||
>>> patch_html_data_attrs(b'<body>test</body>', mode='reauth')
|
||||
b'<html data-mode="reauth"><body>test</body>'
|
||||
"""
|
||||
if not data_attrs:
|
||||
return html
|
||||
|
||||
html_str = html.decode("utf-8")
|
||||
|
||||
# Build the data attributes string
|
||||
attrs_str = " ".join(f'data-{key}="{value}"' for key, value in data_attrs.items())
|
||||
|
||||
# Check if there's an <html> tag (case-insensitive, may have existing attributes)
|
||||
html_tag_pattern = re.compile(r"<html([^>]*)>", re.IGNORECASE)
|
||||
match = html_tag_pattern.search(html_str)
|
||||
|
||||
if match:
|
||||
# Insert data attributes into existing <html> tag
|
||||
existing_attrs = match.group(1)
|
||||
new_tag = f"<html{existing_attrs} {attrs_str}>"
|
||||
html_str = html_tag_pattern.sub(new_tag, html_str, count=1)
|
||||
else:
|
||||
# Prepend <html> tag with data attributes
|
||||
html_str = f"<html {attrs_str}>" + html_str
|
||||
|
||||
return html_str.encode("utf-8")
|
||||
@@ -4,6 +4,7 @@ from collections.abc import Sequence
|
||||
from fnmatch import fnmatchcase
|
||||
|
||||
from ..globals import db
|
||||
from .hostutil import normalize_host
|
||||
from .tokens import session_key
|
||||
|
||||
__all__ = ["has_any", "has_all", "session_context"]
|
||||
@@ -24,5 +25,8 @@ def has_all(ctx, patterns: Sequence[str]) -> bool:
|
||||
return all(_match(ctx.role.permissions, patterns)) if ctx else False
|
||||
|
||||
|
||||
async def session_context(auth: str | None):
|
||||
return await db.instance.get_session_context(session_key(auth)) if auth else None
|
||||
async def session_context(auth: str | None, host: str | None = None):
|
||||
if not auth:
|
||||
return None
|
||||
normalized_host = normalize_host(host) if host else None
|
||||
return await db.instance.get_session_context(session_key(auth), normalized_host)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Utility functions for session validation and checking."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..db import SessionContext
|
||||
from .timeutil import parse_duration
|
||||
|
||||
|
||||
def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
|
||||
"""Check if a session satisfies the max_age requirement.
|
||||
|
||||
Uses the credential's last_used timestamp to determine authentication age,
|
||||
since session renewal can happen without re-authentication.
|
||||
|
||||
Args:
|
||||
ctx: The session context containing session and credential info
|
||||
max_age: Maximum age string (e.g., "5m", "1h", "30s") or None
|
||||
|
||||
Returns:
|
||||
True if authentication is recent enough or max_age is None, False if too old
|
||||
|
||||
Raises:
|
||||
ValueError: If max_age format is invalid
|
||||
"""
|
||||
if not max_age:
|
||||
return True
|
||||
|
||||
max_age_delta = parse_duration(max_age)
|
||||
|
||||
# Use credential's last_used time if available, fall back to session renewed
|
||||
if ctx.credential and ctx.credential.last_used:
|
||||
auth_time = ctx.credential.last_used
|
||||
else:
|
||||
auth_time = ctx.session.renewed
|
||||
|
||||
time_since_auth = datetime.now(timezone.utc) - auth_time
|
||||
return time_since_auth <= max_age_delta
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Utility functions for parsing time durations."""
|
||||
|
||||
import re
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
def parse_duration(duration_str: str) -> timedelta:
|
||||
"""Parse a duration string into a timedelta.
|
||||
|
||||
Supports units: s, m, min, h, d
|
||||
Examples: "30s", "5m", "5min", "2h", "1d"
|
||||
|
||||
Args:
|
||||
duration_str: A string like "30s", "5m", "2h"
|
||||
|
||||
Returns:
|
||||
A timedelta object
|
||||
|
||||
Raises:
|
||||
ValueError: If the format is invalid
|
||||
"""
|
||||
duration_str = duration_str.strip().lower()
|
||||
|
||||
# Pattern matches: number + unit
|
||||
# Units: s (seconds), m/min (minutes), h (hours), d (days)
|
||||
pattern = r"^(\d+(?:\.\d+)?)(s|m|min|h|d)$"
|
||||
match = re.match(pattern, duration_str)
|
||||
|
||||
if not match:
|
||||
raise ValueError(
|
||||
f"Invalid duration format: '{duration_str}'. "
|
||||
"Expected format like '30s', '5m', '5min', '2h', or '1d'"
|
||||
)
|
||||
|
||||
value = float(match.group(1))
|
||||
unit = match.group(2)
|
||||
|
||||
if unit == "s":
|
||||
return timedelta(seconds=value)
|
||||
elif unit in ("m", "min"):
|
||||
return timedelta(minutes=value)
|
||||
elif unit == "h":
|
||||
return timedelta(hours=value)
|
||||
elif unit == "d":
|
||||
return timedelta(days=value)
|
||||
else:
|
||||
raise ValueError(f"Unsupported time unit: {unit}")
|
||||
@@ -15,6 +15,25 @@ def session_key(token: str) -> bytes:
|
||||
return b"sess" + base64.urlsafe_b64decode(token)
|
||||
|
||||
|
||||
def encode_session_key(key: bytes) -> str:
|
||||
"""Encode an opaque session key for external representation."""
|
||||
return base64.urlsafe_b64encode(key).decode().rstrip("=")
|
||||
|
||||
|
||||
def decode_session_key(encoded: str) -> bytes:
|
||||
"""Decode an opaque session key from its public representation."""
|
||||
if not encoded:
|
||||
raise ValueError("Invalid session identifier")
|
||||
padding = "=" * (-len(encoded) % 4)
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(encoded + padding)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
raise ValueError("Invalid session identifier") from exc
|
||||
if not raw.startswith(b"sess"):
|
||||
raise ValueError("Invalid session identifier")
|
||||
return raw
|
||||
|
||||
|
||||
def reset_key(passphrase: str) -> bytes:
|
||||
if not is_well_formed(passphrase):
|
||||
raise ValueError(
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import user_agents
|
||||
|
||||
|
||||
def compact_user_agent(ua: str | None) -> str:
|
||||
if not ua:
|
||||
return "-"
|
||||
u = user_agents.parse(ua)
|
||||
ver = u.browser.version_string.split(".")[0]
|
||||
dev = u.device.family if u.device.family not in ["Other", "Mac"] else ""
|
||||
return f"{u.browser.family}/{ver} {u.os.family} {dev}".strip()
|
||||
@@ -0,0 +1,161 @@
|
||||
"""User information formatting and retrieval logic."""
|
||||
|
||||
from datetime import timezone
|
||||
|
||||
from passkey.util import useragent
|
||||
|
||||
from .. import aaguid
|
||||
from ..authsession import session_key
|
||||
from ..globals import db
|
||||
from . import hostutil, permutil, tokens
|
||||
|
||||
|
||||
def _format_datetime(dt):
|
||||
"""Format a datetime object to ISO 8601 string with UTC timezone."""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo:
|
||||
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
else:
|
||||
return dt.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
async def format_user_info(
|
||||
*,
|
||||
user_uuid,
|
||||
auth: str,
|
||||
session_record,
|
||||
request_host: str | None,
|
||||
) -> dict:
|
||||
"""Format complete user information for authenticated users.
|
||||
|
||||
Args:
|
||||
user_uuid: UUID of the user to fetch information for
|
||||
auth: Authentication token
|
||||
session_record: Current session record
|
||||
request_host: Host header from the request
|
||||
|
||||
Returns:
|
||||
Dictionary containing formatted user information including:
|
||||
- User details
|
||||
- Organization and role information
|
||||
- Credentials list
|
||||
- Sessions list
|
||||
- Permissions
|
||||
"""
|
||||
u = await db.instance.get_user_by_uuid(user_uuid)
|
||||
ctx = await permutil.session_context(auth, request_host)
|
||||
|
||||
# Fetch and format credentials
|
||||
credential_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||
credentials: list[dict] = []
|
||||
user_aaguids: set[str] = set()
|
||||
|
||||
for cred_id in credential_ids:
|
||||
try:
|
||||
c = await db.instance.get_credential_by_id(cred_id)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
aaguid_str = str(c.aaguid)
|
||||
user_aaguids.add(aaguid_str)
|
||||
credentials.append(
|
||||
{
|
||||
"credential_uuid": str(c.uuid),
|
||||
"aaguid": aaguid_str,
|
||||
"created_at": _format_datetime(c.created_at),
|
||||
"last_used": _format_datetime(c.last_used),
|
||||
"last_verified": _format_datetime(c.last_verified),
|
||||
"sign_count": c.sign_count,
|
||||
"is_current_session": session_record.credential_uuid == c.uuid,
|
||||
}
|
||||
)
|
||||
|
||||
credentials.sort(key=lambda cred: cred["created_at"])
|
||||
aaguid_info = aaguid.filter(user_aaguids)
|
||||
|
||||
# Format role and org information
|
||||
role_info = None
|
||||
org_info = None
|
||||
effective_permissions: list[str] = []
|
||||
is_global_admin = False
|
||||
is_org_admin = False
|
||||
|
||||
if ctx:
|
||||
role_info = {
|
||||
"uuid": str(ctx.role.uuid),
|
||||
"display_name": ctx.role.display_name,
|
||||
"permissions": ctx.role.permissions,
|
||||
}
|
||||
org_info = {
|
||||
"uuid": str(ctx.org.uuid),
|
||||
"display_name": ctx.org.display_name,
|
||||
"permissions": ctx.org.permissions,
|
||||
}
|
||||
effective_permissions = [p.id for p in (ctx.permissions or [])]
|
||||
is_global_admin = "auth:admin" in (role_info["permissions"] or [])
|
||||
is_org_admin = any(
|
||||
p.startswith("auth:org:") for p in (role_info["permissions"] or [])
|
||||
)
|
||||
|
||||
# Format sessions
|
||||
normalized_request_host = hostutil.normalize_host(request_host)
|
||||
session_records = await db.instance.list_sessions_for_user(user_uuid)
|
||||
current_session_key = session_key(auth)
|
||||
sessions_payload: list[dict] = []
|
||||
|
||||
for entry in session_records:
|
||||
sessions_payload.append(
|
||||
{
|
||||
"id": tokens.encode_session_key(entry.key),
|
||||
"credential_uuid": str(entry.credential_uuid),
|
||||
"host": entry.host,
|
||||
"ip": entry.ip,
|
||||
"user_agent": useragent.compact_user_agent(entry.user_agent),
|
||||
"last_renewed": _format_datetime(entry.renewed),
|
||||
"is_current": entry.key == current_session_key,
|
||||
"is_current_host": bool(
|
||||
normalized_request_host
|
||||
and entry.host
|
||||
and entry.host == normalized_request_host
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"authenticated": True,
|
||||
"user": {
|
||||
"user_uuid": str(u.uuid),
|
||||
"user_name": u.display_name,
|
||||
"created_at": _format_datetime(u.created_at),
|
||||
"last_seen": _format_datetime(u.last_seen),
|
||||
"visits": u.visits,
|
||||
},
|
||||
"org": org_info,
|
||||
"role": role_info,
|
||||
"permissions": effective_permissions,
|
||||
"is_global_admin": is_global_admin,
|
||||
"is_org_admin": is_org_admin,
|
||||
"credentials": credentials,
|
||||
"aaguid_info": aaguid_info,
|
||||
"sessions": sessions_payload,
|
||||
}
|
||||
|
||||
|
||||
async def format_reset_user_info(user_uuid, reset_token) -> dict:
|
||||
"""Format minimal user information for reset token requests.
|
||||
|
||||
Args:
|
||||
user_uuid: UUID of the user
|
||||
reset_token: Reset token record
|
||||
|
||||
Returns:
|
||||
Dictionary with minimal user info for password reset flow
|
||||
"""
|
||||
u = await db.instance.get_user_by_uuid(user_uuid)
|
||||
|
||||
return {
|
||||
"authenticated": False,
|
||||
"session_type": reset_token.token_type,
|
||||
"user": {"user_uuid": str(u.uuid), "user_name": u.display_name},
|
||||
}
|
||||
+9
-2
@@ -1,10 +1,10 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
requires = ["hatchling", "hatch-vcs"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "passkey"
|
||||
version = "0.1.2"
|
||||
dynamic = ["version"]
|
||||
description = "Passkey Authentication for Web Services"
|
||||
authors = [
|
||||
{name = "Leo Vasanko"},
|
||||
@@ -18,9 +18,16 @@ dependencies = [
|
||||
"aiosqlite>=0.19.0",
|
||||
"uuid7-standard>=1.0.0",
|
||||
"pyjwt>=2.8.0",
|
||||
"user-agents>=2.2.0",
|
||||
]
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[tool.hatch.version]
|
||||
source = "vcs"
|
||||
|
||||
[tool.hatch.build.hooks.vcs]
|
||||
version-file = "passkey/_version.py"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff>=0.1.0",
|
||||
|
||||
+35
-34
@@ -1,47 +1,48 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||
|
||||
|
||||
@contextmanager
|
||||
def chdir(path):
|
||||
original = os.getcwd()
|
||||
os.chdir(path)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
os.chdir(original)
|
||||
def run(cmd, **kwargs):
|
||||
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
|
||||
stderr.write(f"### {' '.join(display_cmd)}\n")
|
||||
subprocess.run(cmd, check=True, **kwargs)
|
||||
|
||||
|
||||
def find_build_tool():
|
||||
install = [
|
||||
("deno", "install", "--allow-scripts=npm:vue-demi"),
|
||||
("npm", "install"),
|
||||
("bun", "--bun", "install"),
|
||||
]
|
||||
|
||||
build = [
|
||||
("deno", "task", "build"),
|
||||
("npm", "run", "build"),
|
||||
("bun", "--bun", "run", "build"),
|
||||
]
|
||||
|
||||
for i, b in zip(install, build, strict=False):
|
||||
if tool := shutil.which(i[0]):
|
||||
return [tool, *i[1:]], [tool, *b[1:]]
|
||||
|
||||
raise RuntimeError("Deno, npm or Bun is required for building but none was found")
|
||||
|
||||
|
||||
class CustomBuildHook(BuildHookInterface):
|
||||
def initialize(self, version, build_data):
|
||||
super().initialize(version, build_data)
|
||||
stderr.write(">>> Building the frontend\n")
|
||||
npm = None
|
||||
bun = shutil.which("bun")
|
||||
if bun is None:
|
||||
npm = shutil.which("npm")
|
||||
if npm is None:
|
||||
raise RuntimeError(
|
||||
"Bun or NodeJS `npm` is required for building but neither was found"
|
||||
)
|
||||
# npm --prefix doesn't work on Windows, so we chdir instead
|
||||
with chdir("frontend"):
|
||||
try:
|
||||
if npm:
|
||||
stderr.write("### npm install\n")
|
||||
subprocess.run([npm, "install"], check=True) # noqa: S603
|
||||
stderr.write("\n### npm run build\n")
|
||||
subprocess.run([npm, "run", "build"], check=True) # noqa: S603
|
||||
else:
|
||||
assert bun
|
||||
stderr.write("### bun --bun install\n")
|
||||
subprocess.run([bun, "--bun", "install"], check=True) # noqa: S603
|
||||
stderr.write("\n### bun --bun run build\n")
|
||||
subprocess.run([bun, "--bun", "run", "build"], check=True) # noqa: S603
|
||||
except Exception:
|
||||
stderr.write("Error occurred while building frontend\n")
|
||||
|
||||
install_cmd, build_cmd = find_build_tool()
|
||||
|
||||
try:
|
||||
run(install_cmd, cwd="frontend")
|
||||
stderr.write("\n")
|
||||
run(build_cmd, cwd="frontend")
|
||||
except Exception as e:
|
||||
stderr.write(f"Error occurred while building frontend: {e}\n")
|
||||
raise
|
||||
|
||||
Reference in New Issue
Block a user