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 |
@@ -41,7 +41,6 @@ Notes:
|
|||||||
| GET | `/auth/` | `/` | Main authentication SPA (non-auth hosts show an account summary view) |
|
| GET | `/auth/` | `/` | Main authentication SPA (non-auth hosts show an account summary view) |
|
||||||
| GET | `/auth/admin/` | `/admin/` | Admin SPA root |
|
| GET | `/auth/admin/` | `/admin/` | Admin SPA root |
|
||||||
| GET | `/auth/{reset_token}` | `/{reset_token}` | Reset / device addition SPA (token validated) |
|
| GET | `/auth/{reset_token}` | `/{reset_token}` | Reset / device addition SPA (token validated) |
|
||||||
| GET | `/auth/restricted` | `/restricted` | Restricted / permission denied SPA |
|
|
||||||
|
|
||||||
## Core API (Unrestricted – available on all hosts)
|
## Core API (Unrestricted – available on all hosts)
|
||||||
|
|
||||||
@@ -49,6 +48,8 @@ Always under `/auth/api/` (even on auth host):
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| 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 |
|
| POST | `/auth/api/validate` | Validate & (conditionally) renew session |
|
||||||
| GET | `/auth/api/forward` | Auth proxy endpoint for reverse proxies (204 or 4xx) |
|
| 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/set-session` | Set cookie from Bearer token |
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,19 +1,25 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
||||||
import CredentialList from '@/components/CredentialList.vue'
|
import CredentialList from '@/components/CredentialList.vue'
|
||||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||||
import StatusMessage from '@/components/StatusMessage.vue'
|
import StatusMessage from '@/components/StatusMessage.vue'
|
||||||
import AdminOverview from './AdminOverview.vue'
|
import LoadingView from '@/components/LoadingView.vue'
|
||||||
import AdminOrgDetail from './AdminOrgDetail.vue'
|
import AuthRequiredMessage from '@/components/AccessDenied.vue'
|
||||||
import AdminUserDetail from './AdminUserDetail.vue'
|
import AdminOverview from '@/admin/AdminOverview.vue'
|
||||||
import AdminDialogs from './AdminDialogs.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 { useAuthStore } from '@/stores/auth'
|
||||||
import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings'
|
import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings'
|
||||||
|
import { apiJson } from '@/utils/api'
|
||||||
|
|
||||||
const info = ref(null)
|
const info = ref(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const loadingMessage = ref('Loading...')
|
||||||
|
const authenticated = ref(false)
|
||||||
|
const showBackMessage = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const orgs = ref([])
|
const orgs = ref([])
|
||||||
const permissions = ref([])
|
const permissions = ref([])
|
||||||
@@ -43,11 +49,17 @@ function handleGlobalClick(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
document.addEventListener('click', handleGlobalClick)
|
document.addEventListener('click', handleGlobalClick)
|
||||||
|
window.addEventListener('hashchange', parseHash)
|
||||||
|
const settings = await getSettings()
|
||||||
|
if (settings?.rp_name) document.title = settings.rp_name + ' Admin'
|
||||||
|
await load()
|
||||||
})
|
})
|
||||||
onBeforeUnmount(() => {
|
|
||||||
|
onUnmounted(() => {
|
||||||
document.removeEventListener('click', handleGlobalClick)
|
document.removeEventListener('click', handleGlobalClick)
|
||||||
|
window.removeEventListener('hashchange', parseHash)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Build a summary: for each permission id -> { orgs: Set(org_display_name), userCount }
|
// Build a summary: for each permission id -> { orgs: Set(org_display_name), userCount }
|
||||||
@@ -99,9 +111,7 @@ async function attachPermissionToOrg(pid, orgUuid) {
|
|||||||
if (!orgUuid) return
|
if (!orgUuid) return
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ permission_id: pid })
|
const params = new URLSearchParams({ permission_id: pid })
|
||||||
const res = await fetch(`/auth/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'POST' })
|
await apiJson(`/auth/api/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'POST' })
|
||||||
const data = await res.json()
|
|
||||||
if (data.detail) throw new Error(data.detail)
|
|
||||||
await loadOrgs()
|
await loadOrgs()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authStore.showMessage(e.message || 'Failed to add permission to org')
|
authStore.showMessage(e.message || 'Failed to add permission to org')
|
||||||
@@ -112,9 +122,7 @@ async function detachPermissionFromOrg(pid, orgUuid) {
|
|||||||
openDialog('confirm', { message: 'Remove permission from this org?', action: async () => {
|
openDialog('confirm', { message: 'Remove permission from this org?', action: async () => {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ permission_id: pid })
|
const params = new URLSearchParams({ permission_id: pid })
|
||||||
const res = await fetch(`/auth/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'DELETE' })
|
await apiJson(`/auth/api/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'DELETE' })
|
||||||
const data = await res.json()
|
|
||||||
if (data.detail) throw new Error(data.detail)
|
|
||||||
await loadOrgs()
|
await loadOrgs()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authStore.showMessage(e.message || 'Failed to remove permission from org')
|
authStore.showMessage(e.message || 'Failed to remove permission from org')
|
||||||
@@ -134,10 +142,7 @@ function parseHash() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadOrgs() {
|
async function loadOrgs() {
|
||||||
const res = await fetch('/auth/admin/orgs')
|
const data = await apiJson('/auth/api/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 => {
|
orgs.value = data.map(o => {
|
||||||
const roles = o.roles.map(r => ({ ...r, org_uuid: o.uuid, users: [] }))
|
const roles = o.roles.map(r => ({ ...r, org_uuid: o.uuid, users: [] }))
|
||||||
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r]))
|
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r]))
|
||||||
@@ -149,25 +154,25 @@ async function loadOrgs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadPermissions() {
|
async function loadPermissions() {
|
||||||
const res = await fetch('/auth/admin/permissions')
|
permissions.value = await apiJson('/auth/api/admin/permissions')
|
||||||
const data = await res.json()
|
}
|
||||||
if (data.detail) throw new Error(data.detail)
|
|
||||||
permissions.value = data
|
async function loadUserInfo() {
|
||||||
|
info.value = await apiJson('/auth/api/user-info', { method: 'POST' })
|
||||||
|
authenticated.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
loadingMessage.value = 'Loading...'
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/auth/api/user-info', { method: 'POST' })
|
// Load admin data first - apiJson will handle 401/403 with iframe authentication
|
||||||
const data = await res.json()
|
await Promise.all([loadOrgs(), loadPermissions()])
|
||||||
if (data.detail) throw new Error(data.detail)
|
// If we get here, user has admin access - now fetch user info for display
|
||||||
info.value = data
|
await loadUserInfo()
|
||||||
if (data.authenticated && (data.is_global_admin || data.is_org_admin)) {
|
|
||||||
await Promise.all([loadOrgs(), loadPermissions()])
|
if (!info.value.is_global_admin && info.value.is_org_admin && orgs.value.length === 1) {
|
||||||
}
|
|
||||||
// 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') {
|
if (!window.location.hash || window.location.hash === '#overview') {
|
||||||
currentOrgId.value = orgs.value[0].uuid
|
currentOrgId.value = orgs.value[0].uuid
|
||||||
window.location.hash = `#org/${currentOrgId.value}`
|
window.location.hash = `#org/${currentOrgId.value}`
|
||||||
@@ -177,7 +182,11 @@ async function load() {
|
|||||||
}
|
}
|
||||||
} else parseHash()
|
} else parseHash()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.message
|
if (e.name === 'AuthCancelledError') {
|
||||||
|
showBackMessage.value = true
|
||||||
|
} else {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -193,8 +202,7 @@ function editUserName(user) { openDialog('user-update-name', { user, name: user.
|
|||||||
function deleteOrg(org) {
|
function deleteOrg(org) {
|
||||||
if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return }
|
if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return }
|
||||||
openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => {
|
openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => {
|
||||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' })
|
await apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'DELETE' })
|
||||||
const data = await res.json(); if (data.detail) throw new Error(data.detail)
|
|
||||||
await Promise.all([loadOrgs(), loadPermissions()])
|
await Promise.all([loadOrgs(), loadPermissions()])
|
||||||
} })
|
} })
|
||||||
}
|
}
|
||||||
@@ -203,14 +211,15 @@ function createUserInRole(org, role) { openDialog('user-create', { org, role })
|
|||||||
|
|
||||||
async function moveUserToRole(org, user, targetRoleDisplayName) {
|
async function moveUserToRole(org, user, targetRoleDisplayName) {
|
||||||
if (user.role === targetRoleDisplayName) return
|
if (user.role === targetRoleDisplayName) return
|
||||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/users/${user.uuid}/role`, {
|
try {
|
||||||
method: 'PUT',
|
await apiJson(`/auth/api/admin/orgs/${org.uuid}/users/${user.uuid}/role`, {
|
||||||
headers: { 'content-type': 'application/json' },
|
method: 'PUT',
|
||||||
body: JSON.stringify({ role: targetRoleDisplayName })
|
body: { role: targetRoleDisplayName }
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
await loadOrgs()
|
||||||
if (data.detail) { authStore.showMessage(data.detail); return }
|
} catch (e) {
|
||||||
await loadOrgs()
|
authStore.showMessage(e.message || 'Failed to update user role')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUserDragStart(e, user, org_uuid) {
|
function onUserDragStart(e, user, org_uuid) {
|
||||||
@@ -245,8 +254,7 @@ function updateRole(role) { openDialog('role-update', { role, name: role.display
|
|||||||
|
|
||||||
function deleteRole(role) {
|
function deleteRole(role) {
|
||||||
openDialog('confirm', { message: `Delete role ${role.display_name}?`, action: async () => {
|
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' })
|
await apiJson(`/auth/api/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()
|
await loadOrgs()
|
||||||
} })
|
} })
|
||||||
}
|
}
|
||||||
@@ -262,13 +270,10 @@ async function toggleRolePermission(role, pid, checked) {
|
|||||||
role.permissions = newPermissions
|
role.permissions = newPermissions
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, {
|
await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'content-type': 'application/json' },
|
body: { display_name: role.display_name, permissions: newPermissions }
|
||||||
body: JSON.stringify({ display_name: role.display_name, permissions: newPermissions })
|
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
|
||||||
if (data.detail) throw new Error(data.detail)
|
|
||||||
await loadOrgs()
|
await loadOrgs()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authStore.showMessage(e.message || 'Failed to update role permission')
|
authStore.showMessage(e.message || 'Failed to update role permission')
|
||||||
@@ -282,18 +287,14 @@ function updatePermission(p) { openDialog('perm-display', { permission: p }) }
|
|||||||
function deletePermission(p) {
|
function deletePermission(p) {
|
||||||
openDialog('confirm', { message: `Delete permission ${p.id}?`, action: async () => {
|
openDialog('confirm', { message: `Delete permission ${p.id}?`, action: async () => {
|
||||||
const params = new URLSearchParams({ permission_id: p.id })
|
const params = new URLSearchParams({ permission_id: p.id })
|
||||||
const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'DELETE' })
|
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' })
|
||||||
const data = await res.json(); if (data.detail) throw new Error(data.detail)
|
|
||||||
await loadPermissions()
|
await loadPermissions()
|
||||||
} })
|
} })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
function reloadPage() {
|
||||||
window.addEventListener('hashchange', parseHash)
|
window.location.reload()
|
||||||
const settings = await getSettings()
|
}
|
||||||
if (settings?.rp_name) document.title = settings.rp_name + ' Admin'
|
|
||||||
load()
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
|
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
|
||||||
|
|
||||||
@@ -350,10 +351,7 @@ const breadcrumbEntries = computed(() => {
|
|||||||
watch(selectedUser, async (u) => {
|
watch(selectedUser, async (u) => {
|
||||||
if (!u) { userDetail.value = null; return }
|
if (!u) { userDetail.value = null; return }
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/auth/admin/orgs/${u.org_uuid}/users/${u.uuid}`)
|
userDetail.value = await apiJson(`/auth/api/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) {
|
} catch (e) {
|
||||||
userDetail.value = { error: e.message }
|
userDetail.value = { error: e.message }
|
||||||
}
|
}
|
||||||
@@ -389,9 +387,7 @@ async function toggleOrgPermission(org, permId, checked) {
|
|||||||
org.permissions = next
|
org.permissions = next
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ permission_id: permId })
|
const params = new URLSearchParams({ permission_id: permId })
|
||||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
|
await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
|
||||||
const data = await res.json()
|
|
||||||
if (data.detail) throw new Error(data.detail)
|
|
||||||
await loadOrgs()
|
await loadOrgs()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authStore.showMessage(e.message || 'Failed to update organization permission')
|
authStore.showMessage(e.message || 'Failed to update organization permission')
|
||||||
@@ -402,16 +398,17 @@ async function toggleOrgPermission(org, permId, checked) {
|
|||||||
function openDialog(type, data) { dialog.value = { type, data, busy: false, error: '' } }
|
function openDialog(type, data) { dialog.value = { type, data, busy: false, error: '' } }
|
||||||
function closeDialog() { dialog.value = { type: null, data: null, busy: false, error: '' } }
|
function closeDialog() { dialog.value = { type: null, data: null, busy: false, error: '' } }
|
||||||
|
|
||||||
async function onUserNameSaved() {
|
async function refreshUserDetail() {
|
||||||
await loadOrgs()
|
await loadOrgs()
|
||||||
if (selectedUser.value) {
|
if (selectedUser.value) {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/auth/admin/orgs/${selectedUser.value.org_uuid}/users/${selectedUser.value.uuid}`)
|
userDetail.value = await apiJson(`/auth/api/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') }
|
} catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUserNameSaved() {
|
||||||
|
await refreshUserDetail()
|
||||||
authStore.showMessage('User renamed', 'success', 1500)
|
authStore.showMessage('User renamed', 'success', 1500)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,28 +419,28 @@ async function submitDialog() {
|
|||||||
const t = dialog.value.type
|
const t = dialog.value.type
|
||||||
if (t === 'org-create') {
|
if (t === 'org-create') {
|
||||||
const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
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: [] }) })
|
await apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } })
|
||||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await Promise.all([loadOrgs(), loadPermissions()])
|
await Promise.all([loadOrgs(), loadPermissions()])
|
||||||
} else if (t === 'org-update') {
|
} 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 { 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 }) })
|
await apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PUT', body: { display_name: name, permissions: org.permissions } })
|
||||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
await loadOrgs()
|
||||||
} else if (t === 'role-create') {
|
} 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 { 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: [] }) })
|
await apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } })
|
||||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
await loadOrgs()
|
||||||
} else if (t === 'role-update') {
|
} 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 { 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/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: role.permissions }) })
|
await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', body: { display_name: name, permissions: role.permissions } })
|
||||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
await loadOrgs()
|
||||||
} else if (t === 'user-create') {
|
} 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 { 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 }) })
|
await apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } })
|
||||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
await loadOrgs()
|
||||||
} else if (t === 'user-update-name') {
|
} 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')
|
const { user } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||||
const res = await fetch(`/auth/admin/orgs/${user.org_uuid}/users/${user.uuid}/display-name`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name }) })
|
await apiJson(`/auth/api/admin/orgs/${user.org_uuid}/users/${user.uuid}/display-name`, { method: 'PUT', body: { display_name: name } })
|
||||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await onUserNameSaved()
|
await onUserNameSaved()
|
||||||
} else if (t === 'perm-display') {
|
} else if (t === 'perm-display') {
|
||||||
const { permission } = dialog.value.data
|
const { permission } = dialog.value.data
|
||||||
const newId = dialog.value.data.id?.trim()
|
const newId = dialog.value.data.id?.trim()
|
||||||
@@ -453,22 +450,17 @@ async function submitDialog() {
|
|||||||
|
|
||||||
if (newId !== permission.id) {
|
if (newId !== permission.id) {
|
||||||
// ID changed, use rename endpoint
|
// ID changed, use rename endpoint
|
||||||
const body = { old_id: permission.id, new_id: newId, display_name: newDisplay }
|
await apiJson('/auth/api/admin/permission/rename', { method: 'POST', body: { old_id: permission.id, new_id: newId, display_name: newDisplay } })
|
||||||
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})`)
|
|
||||||
} else if (newDisplay !== permission.display_name) {
|
} else if (newDisplay !== permission.display_name) {
|
||||||
// Only display name changed
|
// Only display name changed
|
||||||
const params = new URLSearchParams({ permission_id: permission.id, display_name: newDisplay })
|
const params = new URLSearchParams({ permission_id: permission.id, display_name: newDisplay })
|
||||||
const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'PUT' })
|
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PUT' })
|
||||||
const d = await res.json(); if (d.detail) throw new Error(d.detail)
|
|
||||||
}
|
}
|
||||||
await loadPermissions()
|
await loadPermissions()
|
||||||
} else if (t === 'perm-create') {
|
} else if (t === 'perm-create') {
|
||||||
const id = dialog.value.data.id?.trim(); if (!id) throw new Error('ID required')
|
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')
|
const display_name = dialog.value.data.display_name?.trim(); if (!display_name) throw new Error('Display name required')
|
||||||
const res = await fetch('/auth/admin/permissions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, display_name }) })
|
await apiJson('/auth/api/admin/permissions', { method: 'POST', body: { id, display_name } })
|
||||||
const data = await res.json(); if (data.detail) throw new Error(data.detail)
|
|
||||||
await loadPermissions(); dialog.value.data.display_name = ''; dialog.value.data.id = ''
|
await loadPermissions(); dialog.value.data.display_name = ''; dialog.value.data.id = ''
|
||||||
} else if (t === 'confirm') {
|
} else if (t === 'confirm') {
|
||||||
const action = dialog.value.data.action; if (action) await action()
|
const action = dialog.value.data.action; if (action) await action()
|
||||||
@@ -484,76 +476,71 @@ async function submitDialog() {
|
|||||||
<div class="app-shell admin-shell">
|
<div class="app-shell admin-shell">
|
||||||
<StatusMessage />
|
<StatusMessage />
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<section class="view-root view-admin">
|
<LoadingView v-if="loading" :message="loadingMessage" />
|
||||||
<div class="view-content view-content--wide">
|
<AuthRequiredMessage
|
||||||
<header class="view-header">
|
v-else-if="showBackMessage"
|
||||||
<h1>{{ pageHeading }}</h1>
|
@reload="reloadPage"
|
||||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
/>
|
||||||
</header>
|
<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">
|
<section class="section-block admin-section">
|
||||||
<div class="section-body admin-section-body">
|
<div class="section-body admin-section-body">
|
||||||
<div v-if="loading" class="surface surface--tight">Loading…</div>
|
<div v-if="error" class="surface surface--tight error">{{ error }}</div>
|
||||||
<div v-else-if="error" class="surface surface--tight error">{{ error }}</div>
|
<div v-else class="admin-panels">
|
||||||
<template v-else>
|
<AdminOverview
|
||||||
<div v-if="!info?.authenticated" class="surface surface--tight">
|
v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)"
|
||||||
<p>You must be authenticated.</p>
|
:info="info"
|
||||||
</div>
|
:orgs="orgs"
|
||||||
<div v-else-if="!(info?.is_global_admin || info?.is_org_admin)" class="surface surface--tight">
|
:permissions="permissions"
|
||||||
<p>Insufficient permissions.</p>
|
:permission-summary="permissionSummary"
|
||||||
</div>
|
@create-org="createOrg"
|
||||||
<div v-else class="admin-panels">
|
@open-org="openOrg"
|
||||||
<AdminOverview
|
@update-org="updateOrg"
|
||||||
v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)"
|
@delete-org="deleteOrg"
|
||||||
:info="info"
|
@toggle-org-permission="toggleOrgPermission"
|
||||||
:orgs="orgs"
|
@open-dialog="openDialog"
|
||||||
:permissions="permissions"
|
@delete-permission="deletePermission"
|
||||||
:permission-summary="permissionSummary"
|
@rename-permission-display="renamePermissionDisplay"
|
||||||
@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
|
<AdminUserDetail
|
||||||
v-else-if="selectedUser"
|
v-else-if="selectedUser"
|
||||||
:selected-user="selectedUser"
|
:selected-user="selectedUser"
|
||||||
:user-detail="userDetail"
|
:user-detail="userDetail"
|
||||||
:selected-org="selectedOrg"
|
:selected-org="selectedOrg"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:show-reg-modal="showRegModal"
|
:show-reg-modal="showRegModal"
|
||||||
@generate-user-registration-link="generateUserRegistrationLink"
|
@generate-user-registration-link="generateUserRegistrationLink"
|
||||||
@go-overview="goOverview"
|
@go-overview="goOverview"
|
||||||
@open-org="openOrg"
|
@open-org="openOrg"
|
||||||
@on-user-name-saved="onUserNameSaved"
|
@on-user-name-saved="onUserNameSaved"
|
||||||
@edit-user-name="editUserName"
|
@refresh-user-detail="refreshUserDetail"
|
||||||
@close-reg-modal="showRegModal = false"
|
@edit-user-name="editUserName"
|
||||||
/>
|
@close-reg-modal="showRegModal = false"
|
||||||
<AdminOrgDetail
|
/>
|
||||||
v-else-if="selectedOrg"
|
<AdminOrgDetail
|
||||||
:selected-org="selectedOrg"
|
v-else-if="selectedOrg"
|
||||||
:permissions="permissions"
|
:selected-org="selectedOrg"
|
||||||
@update-org="updateOrg"
|
:permissions="permissions"
|
||||||
@create-role="createRole"
|
@update-org="updateOrg"
|
||||||
@update-role="updateRole"
|
@create-role="createRole"
|
||||||
@delete-role="deleteRole"
|
@update-role="updateRole"
|
||||||
@create-user-in-role="createUserInRole"
|
@delete-role="deleteRole"
|
||||||
@open-user="openUser"
|
@create-user-in-role="createUserInRole"
|
||||||
@toggle-role-permission="toggleRolePermission"
|
@open-user="openUser"
|
||||||
@on-role-drag-over="onRoleDragOver"
|
@toggle-role-permission="toggleRolePermission"
|
||||||
@on-role-drop="onRoleDrop"
|
@on-role-drag-over="onRoleDragOver"
|
||||||
@on-user-drag-start="onUserDragStart"
|
@on-role-drop="onRoleDrop"
|
||||||
/>
|
@on-user-drag-start="onUserDragStart"
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<AdminDialogs
|
<AdminDialogs
|
||||||
@@ -7,6 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="admin-app"></div>
|
<div id="admin-app"></div>
|
||||||
<script type="module" src="/src/admin/main.js"></script>
|
<script type="module" src="./main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import '../assets/style.css'
|
import '@/assets/style.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
@@ -7,6 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import './assets/style.css'
|
import '@/assets/style.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
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')
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Account Summary</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/host/main.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -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>
|
||||||
@@ -7,6 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/restricted/main.js"></script>
|
<script type="module" src="/int/forward/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './RestrictedForward.vue'
|
||||||
|
import '@/assets/style.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -7,52 +7,50 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="view-root">
|
<main class="view-root">
|
||||||
<div class="view-content">
|
<div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;">
|
||||||
<div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;">
|
<header class="view-header" style="text-align: center;">
|
||||||
<header class="view-header" style="text-align: center;">
|
<h1>🔑 Registration</h1>
|
||||||
<h1>🔑 Registration</h1>
|
<p class="view-lede">
|
||||||
<p class="view-lede">
|
{{ subtitleMessage }}
|
||||||
{{ subtitleMessage }}
|
</p>
|
||||||
</p>
|
</header>
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="section-block" v-if="initializing">
|
<section class="section-block" v-if="initializing">
|
||||||
<div class="section-body center">
|
<div class="section-body center">
|
||||||
<p>Loading reset details…</p>
|
<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>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="section-block" v-else-if="!canRegister">
|
<section class="section-block" v-else>
|
||||||
<div class="section-body center">
|
<div class="section-body">
|
||||||
<p>{{ errorMessage }}</p>
|
<label class="name-edit">
|
||||||
<div class="button-row center" style="justify-content: center;">
|
<span>👤 Name</span>
|
||||||
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
|
<input
|
||||||
</div>
|
type="text"
|
||||||
</div>
|
v-model="displayName"
|
||||||
</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"
|
:disabled="loading"
|
||||||
@click="registerPasskey"
|
maxlength="64"
|
||||||
>
|
@keyup.enter="registerPasskey"
|
||||||
{{ loading ? 'Registering…' : 'Register Passkey' }}
|
/>
|
||||||
</button>
|
</label>
|
||||||
</div>
|
<button
|
||||||
</section>
|
class="btn-primary"
|
||||||
</div>
|
:disabled="loading"
|
||||||
|
@click="registerPasskey"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Registering…' : 'Register Passkey' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,6 +60,7 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import passkey from '@/utils/passkey'
|
import passkey from '@/utils/passkey'
|
||||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||||
|
import { apiJson, ApiError, getUserFriendlyErrorMessage } from '@/utils/api'
|
||||||
|
|
||||||
const status = reactive({
|
const status = reactive({
|
||||||
show: false,
|
show: false,
|
||||||
@@ -114,21 +113,15 @@ async function fetchSettings() {
|
|||||||
async function fetchUserInfo() {
|
async function fetchUserInfo() {
|
||||||
if (!token.value) return
|
if (!token.value) return
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/auth/api/user-info?reset=${encodeURIComponent(token.value)}`, {
|
userInfo.value = await apiJson(`/auth/api/user-info?reset=${encodeURIComponent(token.value)}`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
|
||||||
const payload = await safeParseJson(res)
|
|
||||||
const detail = payload?.detail || 'Reset link is invalid or expired.'
|
|
||||||
errorMessage.value = detail
|
|
||||||
showMessage(detail, 'error', 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userInfo.value = await res.json()
|
|
||||||
displayName.value = userInfo.value?.user?.user_name || ''
|
displayName.value = userInfo.value?.user?.user_name || ''
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load user info', error)
|
console.error('Failed to load user info', error)
|
||||||
const message = 'We could not load your reset details. Try refreshing the page.'
|
const message = error instanceof ApiError
|
||||||
|
? (error.data?.detail || 'Reset link is invalid or expired.')
|
||||||
|
: getUserFriendlyErrorMessage(error)
|
||||||
errorMessage.value = message
|
errorMessage.value = message
|
||||||
showMessage(message, 'error', 0)
|
showMessage(message, 'error', 0)
|
||||||
}
|
}
|
||||||
@@ -152,7 +145,7 @@ async function registerPasskey() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setSessionCookie(result.session_token)
|
await setSessionCookie(result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
const message = error?.message || 'Failed to establish session'
|
const message = error?.message || 'Failed to establish session'
|
||||||
@@ -160,29 +153,23 @@ async function registerPasskey() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
showMessage('Passkey registered successfully!', 'success', 2000)
|
showMessage('Passkey registered successfully!', 'success', 800)
|
||||||
setTimeout(() => {
|
setTimeout(() => { loading.value = false; goHome() }, 800)
|
||||||
loading.value = false
|
|
||||||
redirectHome()
|
|
||||||
}, 800)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setSessionCookie(sessionToken) {
|
async function setSessionCookie(result) {
|
||||||
const response = await fetch('/auth/api/set-session', {
|
if (!result?.session_token) {
|
||||||
|
throw new Error('Registration response missing session_token')
|
||||||
|
}
|
||||||
|
return await apiJson('/auth/api/set-session', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${sessionToken}`
|
Authorization: `Bearer ${result.session_token}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const payload = await safeParseJson(response)
|
|
||||||
if (!response.ok || payload?.detail) {
|
|
||||||
const detail = payload?.detail || 'Session could not be established.'
|
|
||||||
throw new Error(detail)
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function redirectHome() {
|
function goHome() {
|
||||||
const target = uiBasePath.value || '/auth/'
|
const target = uiBasePath.value || '/auth/'
|
||||||
if (window.location.pathname !== target) {
|
if (window.location.pathname !== target) {
|
||||||
history.replaceState(null, '', target)
|
history.replaceState(null, '', target)
|
||||||
@@ -190,10 +177,6 @@ function redirectHome() {
|
|||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
function goHome() {
|
|
||||||
redirectHome()
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTokenFromPath() {
|
function extractTokenFromPath() {
|
||||||
const segments = window.location.pathname.split('/').filter(Boolean)
|
const segments = window.location.pathname.split('/').filter(Boolean)
|
||||||
if (!segments.length) return ''
|
if (!segments.length) return ''
|
||||||
@@ -205,14 +188,6 @@ function extractTokenFromPath() {
|
|||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
async function safeParseJson(response) {
|
|
||||||
try {
|
|
||||||
return await response.json()
|
|
||||||
} catch (error) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
token.value = extractTokenFromPath()
|
token.value = extractTokenFromPath()
|
||||||
await fetchSettings()
|
await fetchSettings()
|
||||||
@@ -7,6 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/reset/main.js"></script>
|
<script type="module" src="/int/reset/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"@simplewebauthn/browser": "^13.1.2",
|
"@simplewebauthn/browser": "^13.1.2",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"sirv": "^3.0.2",
|
||||||
"vue": "^3.5.17"
|
"vue": "^3.5.17"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="app-shell">
|
|
||||||
<StatusMessage />
|
|
||||||
<main class="app-main">
|
|
||||||
<ProfileView v-if="initialized" />
|
|
||||||
<div v-else class="loading-container">
|
|
||||||
<div class="loading-spinner"></div>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import StatusMessage from '@/components/StatusMessage.vue'
|
|
||||||
import ProfileView from '@/components/ProfileView.vue'
|
|
||||||
const store = useAuthStore()
|
|
||||||
const initialized = ref(false)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await store.loadSettings()
|
|
||||||
if (store.settings?.rp_name) document.title = store.settings.rp_name
|
|
||||||
try { await store.loadUserInfo() } catch (_) { /* user info load errors ignored */ }
|
|
||||||
initialized.value = true
|
|
||||||
})
|
|
||||||
</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>
|
|
||||||
@@ -5,6 +5,7 @@ import CredentialList from '@/components/CredentialList.vue'
|
|||||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||||
import SessionList from '@/components/SessionList.vue'
|
import SessionList from '@/components/SessionList.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { apiJson } from '@/utils/api'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
selectedUser: Object,
|
selectedUser: Object,
|
||||||
@@ -14,9 +15,12 @@ const props = defineProps({
|
|||||||
showRegModal: Boolean
|
showRegModal: Boolean
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName'])
|
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail'])
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const terminatingSessions = ref({})
|
||||||
|
const hoveredCredentialUuid = ref(null)
|
||||||
|
const hoveredSession = ref(null)
|
||||||
|
|
||||||
function onLinkCopied() {
|
function onLinkCopied() {
|
||||||
authStore.showMessage('Link copied to clipboard!')
|
authStore.showMessage('Link copied to clipboard!')
|
||||||
@@ -26,17 +30,44 @@ function handleEditName() {
|
|||||||
emit('editUserName', props.selectedUser)
|
emit('editUserName', props.selectedUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete(credential) {
|
async function handleDelete(credential) {
|
||||||
fetch(`/auth/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/credentials/${credential.credential_uuid}`, { method: 'DELETE' })
|
try {
|
||||||
.then(res => res.json())
|
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/credentials/${credential.credential_uuid}`, { method: 'DELETE' })
|
||||||
.then(data => {
|
if (data.status === 'ok') {
|
||||||
if (data.status === 'ok') {
|
emit('onUserNameSaved') // Reuse to refresh user detail
|
||||||
emit('onUserNameSaved') // Reuse to refresh user detail
|
} else {
|
||||||
} else {
|
console.error('Failed to delete credential', data)
|
||||||
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
|
||||||
.catch(err => console.error('Delete credential error', err))
|
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>
|
</script>
|
||||||
@@ -52,7 +83,7 @@ function handleDelete(credential) {
|
|||||||
:loading="loading"
|
:loading="loading"
|
||||||
:org-display-name="userDetail.org.display_name"
|
:org-display-name="userDetail.org.display_name"
|
||||||
:role-name="userDetail.role"
|
:role-name="userDetail.role"
|
||||||
:update-endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
|
:update-endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
|
||||||
@saved="$emit('onUserNameSaved')"
|
@saved="$emit('onUserNameSaved')"
|
||||||
@edit-name="handleEditName"
|
@edit-name="handleEditName"
|
||||||
/>
|
/>
|
||||||
@@ -78,15 +109,21 @@ function handleDelete(credential) {
|
|||||||
:credentials="userDetail.credentials"
|
:credentials="userDetail.credentials"
|
||||||
:aaguid-info="userDetail.aaguid_info"
|
:aaguid-info="userDetail.aaguid_info"
|
||||||
:allow-delete="true"
|
:allow-delete="true"
|
||||||
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||||
|
:hovered-session-credential-uuid="hoveredSession?.credential_uuid"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
|
@credential-hover="hoveredCredentialUuid = $event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<SessionList
|
<SessionList
|
||||||
:sessions="userDetail.sessions || []"
|
:sessions="userDetail.sessions || []"
|
||||||
:allow-terminate="false"
|
:terminating-sessions="terminatingSessions"
|
||||||
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||||
:empty-message="'This user has no active sessions.'"
|
:empty-message="'This user has no active sessions.'"
|
||||||
:section-description="'View the active sessions for this user.'"
|
:section-description="'View and manage the active sessions for this user.'"
|
||||||
|
@terminate="handleTerminateSession"
|
||||||
|
@session-hover="hoveredSession = $event"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<div class="actions ancillary-actions">
|
<div class="actions ancillary-actions">
|
||||||
@@ -94,7 +131,7 @@ function handleDelete(credential) {
|
|||||||
</div>
|
</div>
|
||||||
<RegistrationLinkModal
|
<RegistrationLinkModal
|
||||||
v-if="showRegModal"
|
v-if="showRegModal"
|
||||||
:endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
|
:endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
|
||||||
:auto-copy="false"
|
:auto-copy="false"
|
||||||
:user-name="userDetail?.display_name || selectedUser.display_name"
|
:user-name="userDetail?.display_name || selectedUser.display_name"
|
||||||
@close="$emit('closeRegModal')"
|
@close="$emit('closeRegModal')"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
|
||||||
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
|
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
|
||||||
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
--color-canvas: #f5f6f8;
|
--color-canvas: #f5f6f8;
|
||||||
@@ -35,14 +34,13 @@
|
|||||||
--space-lg: 1.5rem;
|
--space-lg: 1.5rem;
|
||||||
--space-xl: 2.25rem;
|
--space-xl: 2.25rem;
|
||||||
--space-xxl: 3.5rem;
|
--space-xxl: 3.5rem;
|
||||||
--layout-max-width: 1080px;
|
--layout-max-width: 1400px;
|
||||||
--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);
|
--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);
|
||||||
--transition-base: 160ms ease;
|
--transition-base: 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
|
||||||
--color-canvas: #0f172a;
|
--color-canvas: #0f172a;
|
||||||
--color-surface: #141b2f;
|
--color-surface: #141b2f;
|
||||||
--color-surface-subtle: #1b243b;
|
--color-surface-subtle: #1b243b;
|
||||||
@@ -57,13 +55,13 @@
|
|||||||
--color-accent-strong: #3b82f6;
|
--color-accent-strong: #3b82f6;
|
||||||
--color-accent-contrast: #0b1120;
|
--color-accent-contrast: #0b1120;
|
||||||
--color-success-text: #34d399;
|
--color-success-text: #34d399;
|
||||||
--color-success-bg: rgba(34, 197, 94, 0.12);
|
--color-success-bg: #1a4d2e;
|
||||||
--color-error-text: #fca5a5;
|
--color-error-text: #fca5a5;
|
||||||
--color-error-bg: rgba(248, 113, 113, 0.16);
|
--color-error-bg: #4a1f1f;
|
||||||
--color-info-text: #bae6fd;
|
--color-info-text: #bae6fd;
|
||||||
--color-info-bg: rgba(59, 130, 246, 0.16);
|
--color-info-bg: #1e3a5f;
|
||||||
--color-danger: #f87171;
|
--color-danger: #f87171;
|
||||||
--shadow-soft: 0 0 0 rgba(0, 0, 0, 0);
|
--shadow-soft: 0 0 0 #000000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,15 +71,16 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html {
|
||||||
body {
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
background: var(--color-canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
background: var(--color-canvas);
|
background: none;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@@ -123,7 +122,6 @@ a:focus-visible {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--color-canvas);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
@@ -136,23 +134,22 @@ a:focus-visible {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: var(--layout-padding);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-content {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
|
padding: var(--layout-padding);
|
||||||
|
box-sizing: border-box;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: min(100%, var(--layout-max-width));
|
width: min(100%, var(--layout-max-width));
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-content--wide {
|
.view-root--wide {
|
||||||
width: min(100%, 1200px);
|
width: min(100%, 1200px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.view-root--narrow {
|
||||||
|
max-width: 540px;
|
||||||
|
}
|
||||||
|
|
||||||
.view-header {
|
.view-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -232,8 +229,8 @@ button:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
filter: opacity(0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@@ -288,7 +285,7 @@ input:focus-visible,
|
|||||||
textarea:focus-visible,
|
textarea:focus-visible,
|
||||||
select:focus-visible {
|
select:focus-visible {
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
box-shadow: 0 0 0 3px #c7d2fe;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,19 +369,19 @@ th {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status.info {
|
.status.info {
|
||||||
border-color: rgba(14, 96, 155, 0.28);
|
border-color: #3b82f6;
|
||||||
color: var(--color-info-text);
|
color: var(--color-info-text);
|
||||||
background: var(--color-info-bg);
|
background: var(--color-info-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.success {
|
.status.success {
|
||||||
border-color: rgba(6, 118, 71, 0.22);
|
border-color: #16a34a;
|
||||||
color: var(--color-success-text);
|
color: var(--color-success-text);
|
||||||
background: var(--color-success-bg);
|
background: var(--color-success-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.error {
|
.status.error {
|
||||||
border-color: rgba(180, 35, 24, 0.28);
|
border-color: #dc2626;
|
||||||
color: var(--color-error-text);
|
color: var(--color-error-text);
|
||||||
background: var(--color-error-bg);
|
background: var(--color-error-bg);
|
||||||
}
|
}
|
||||||
@@ -392,8 +389,9 @@ th {
|
|||||||
.dialog-overlay {
|
.dialog-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(9, 14, 24, 0.55);
|
background: transparent;
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(.1rem) brightness(0.7);
|
||||||
|
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
||||||
z-index: 1100;
|
z-index: 1100;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -447,18 +445,11 @@ th {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-flow: row;
|
grid-auto-flow: row;
|
||||||
grid-template-columns: repeat(auto-fit, var(--card-width));
|
grid-template-columns: repeat(auto-fill, minmax(var(--card-width), 1fr));
|
||||||
justify-content: start;
|
justify-content: start;
|
||||||
gap: 1rem 1.25rem;
|
gap: 1rem 1.25rem;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-width: calc(var(--card-width) * 4 + 3 * 1.25rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
|
||||||
.record-list,
|
|
||||||
.credential-list,
|
|
||||||
.session-list { max-width: calc(var(--card-width) * 3 + 2 * 1.25rem); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -478,6 +469,7 @@ th {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-item:hover,
|
.record-item:hover,
|
||||||
@@ -490,7 +482,12 @@ th {
|
|||||||
|
|
||||||
.record-item.is-current,
|
.record-item.is-current,
|
||||||
.credential-item.current-session,
|
.credential-item.current-session,
|
||||||
.session-item.is-current { border-color: var(--color-accent); }
|
.credential-item.is-hovered,
|
||||||
|
.session-item.is-current,
|
||||||
|
.session-item.is-hovered { border-color: var(--color-accent); }
|
||||||
|
|
||||||
|
.credential-item.is-linked-session,
|
||||||
|
.session-item.is-linked-credential { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
|
||||||
|
|
||||||
.item-top {
|
.item-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -568,8 +565,8 @@ th {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-card-delete { background: transparent; border: none; color: var(--color-danger); padding: 0.35rem 0.5rem; font-size: 1.05rem; line-height: 1; border-radius: var(--radius-sm); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
.btn-card-delete { background: transparent; border: none; color: var(--color-danger); padding: 0.35rem 0.5rem; font-size: 1.05rem; line-height: 1; border-radius: var(--radius-sm); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
.btn-card-delete:hover:not(:disabled) { background: rgba(220, 38, 38, 0.08); }
|
.btn-card-delete:hover:not(:disabled) { background: #fee; }
|
||||||
.btn-card-delete:disabled { opacity: 0.4; cursor: not-allowed; }
|
.btn-card-delete:disabled { filter: opacity(0.4); cursor: not-allowed; }
|
||||||
|
|
||||||
|
|
||||||
.session-emoji {
|
.session-emoji {
|
||||||
@@ -653,9 +650,6 @@ th {
|
|||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.view-root {
|
.view-root {
|
||||||
padding: clamp(1rem, 3vw + 0.75rem, 2rem);
|
padding: clamp(1rem, 3vw + 0.75rem, 2rem);
|
||||||
}
|
|
||||||
|
|
||||||
.view-content {
|
|
||||||
gap: 1.75rem;
|
gap: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,6 +658,13 @@ th {
|
|||||||
grid-template-columns: auto auto;
|
grid-template-columns: auto auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.global-status {
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile portrait (touch) or very narrow screens: stack buttons */
|
||||||
|
@media (max-width: 500px) and (orientation: portrait) and (pointer: coarse), (max-width: 350px) {
|
||||||
button {
|
button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -671,10 +672,6 @@ th {
|
|||||||
.button-row {
|
.button-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.global-status {
|
|
||||||
top: 1rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-backdrop {
|
.dialog-backdrop {
|
||||||
@@ -683,8 +680,9 @@ th {
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: transparent;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(.1rem) brightness(0.7);
|
||||||
|
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -704,7 +702,7 @@ th {
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 20px 60px #1e293b;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,3 +719,22 @@ th {
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Auth iframe overlay styles */
|
||||||
|
body:has(#auth-iframe) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#auth-iframe {
|
||||||
|
border: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 9999;
|
||||||
|
color-scheme: auto;
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: blur(.1rem) brightness(0.7);
|
||||||
|
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -6,7 +6,14 @@
|
|||||||
<div
|
<div
|
||||||
v-for="credential in credentials"
|
v-for="credential in credentials"
|
||||||
:key="credential.credential_uuid"
|
: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="item-top">
|
<div class="item-top">
|
||||||
<div class="item-icon">
|
<div class="item-icon">
|
||||||
@@ -22,7 +29,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
|
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<span v-if="credential.is_current_session" class="badge badge-current">Current</span>
|
<span v-if="credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid" class="badge badge-current">Current</span>
|
||||||
|
<span v-else-if="hoveredCredentialUuid === credential.credential_uuid" class="badge badge-current">Selected</span>
|
||||||
|
<span v-else-if="hoveredSessionCredentialUuid === credential.credential_uuid" class="badge badge-current">Linked</span>
|
||||||
<button
|
<button
|
||||||
v-if="allowDelete"
|
v-if="allowDelete"
|
||||||
@click="$emit('delete', credential)"
|
@click="$emit('delete', credential)"
|
||||||
@@ -48,7 +57,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { formatDate } from '@/utils/helpers'
|
import { formatDate } from '@/utils/helpers'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -56,8 +65,23 @@ const props = defineProps({
|
|||||||
aaguidInfo: { type: Object, default: () => ({}) },
|
aaguidInfo: { type: Object, default: () => ({}) },
|
||||||
loading: { type: Boolean, default: false },
|
loading: { type: Boolean, default: false },
|
||||||
allowDelete: { 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 getCredentialAuthName = (credential) => {
|
||||||
const info = props.aaguidInfo?.[credential.aaguid]
|
const info = props.aaguidInfo?.[credential.aaguid]
|
||||||
return info ? info.name : 'Unknown Authenticator'
|
return info ? info.name : 'Unknown Authenticator'
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="view-root view-device-link">
|
<section class="view-root view-root--narrow view-device-link">
|
||||||
<div class="view-content view-content--narrow">
|
<header class="view-header">
|
||||||
<header class="view-header">
|
<h1>📱 Add Another Device</h1>
|
||||||
<h1>📱 Add Another Device</h1>
|
<p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p>
|
||||||
<p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p>
|
</header>
|
||||||
</header>
|
<RegistrationLinkModal
|
||||||
<RegistrationLinkModal
|
inline
|
||||||
inline
|
:endpoint="'/auth/api/user/create-link'"
|
||||||
:endpoint="'/auth/api/user/create-link'"
|
:user-name="userName"
|
||||||
:user-name="userName"
|
:auto-copy="false"
|
||||||
:auto-copy="false"
|
:prefix-copy-with-user-name="!!userName"
|
||||||
:prefix-copy-with-user-name="!!userName"
|
show-close-in-inline
|
||||||
show-close-in-inline
|
@copied="onCopied"
|
||||||
@copied="onCopied"
|
/>
|
||||||
/>
|
<div class="button-row" style="margin-top:1rem;">
|
||||||
<div class="button-row" style="margin-top:1rem;">
|
<button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
|
||||||
<button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -43,10 +41,6 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.view-content--narrow {
|
|
||||||
max-width: 540px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-lede {
|
.view-lede {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -17,8 +17,9 @@ defineEmits(['close'])
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: transparent;
|
||||||
backdrop-filter: blur(.1rem);
|
backdrop-filter: blur(.1rem) brightness(0.7);
|
||||||
|
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -80,7 +81,7 @@ defineEmits(['close'])
|
|||||||
.modal :deep(.modal-form textarea:focus) {
|
.modal :deep(.modal-form textarea:focus) {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
box-shadow: 0 0 0 2px #c7d2fe;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal :deep(.modal-actions) {
|
.modal :deep(.modal-actions) {
|
||||||
|
|||||||
@@ -1,92 +1,95 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="view-root" data-view="profile">
|
<section class="view-root" data-view="profile">
|
||||||
<div class="view-content">
|
<header class="view-header">
|
||||||
<header class="view-header">
|
<h1>👋 Welcome!</h1>
|
||||||
<h1>👋 Welcome!</h1>
|
<Breadcrumbs :entries="breadcrumbEntries" />
|
||||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
<p class="view-lede">Manage your account details and passkeys.</p>
|
||||||
<p class="view-lede">Manage your account details and passkeys.</p>
|
</header>
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="section-block">
|
<section class="section-block">
|
||||||
<UserBasicInfo
|
<UserBasicInfo
|
||||||
v-if="authStore.userInfo?.user"
|
v-if="authStore.userInfo?.user"
|
||||||
:name="authStore.userInfo.user.user_name"
|
:name="authStore.userInfo.user.user_name"
|
||||||
:visits="authStore.userInfo.user.visits || 0"
|
:visits="authStore.userInfo.user.visits || 0"
|
||||||
:created-at="authStore.userInfo.user.created_at"
|
:created-at="authStore.userInfo.user.created_at"
|
||||||
:last-seen="authStore.userInfo.user.last_seen"
|
:last-seen="authStore.userInfo.user.last_seen"
|
||||||
|
:loading="authStore.isLoading"
|
||||||
|
update-endpoint="/auth/api/user/display-name"
|
||||||
|
@saved="authStore.loadUserInfo()"
|
||||||
|
@edit-name="openNameDialog"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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"
|
:loading="authStore.isLoading"
|
||||||
update-endpoint="/auth/api/user/display-name"
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||||
@saved="authStore.loadUserInfo()"
|
:hovered-session-credential-uuid="hoveredSession?.credential_uuid"
|
||||||
@edit-name="openNameDialog"
|
allow-delete
|
||||||
|
@delete="handleDelete"
|
||||||
|
@credential-hover="hoveredCredentialUuid = $event"
|
||||||
/>
|
/>
|
||||||
</section>
|
<div class="button-row">
|
||||||
|
<button @click="addNewCredential" class="btn-primary">Add New Passkey</button>
|
||||||
<section class="section-block">
|
<button @click="showRegLink = true" class="btn-secondary">Add Another Device</button>
|
||||||
<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>
|
||||||
<div class="section-body">
|
</div>
|
||||||
<CredentialList
|
</section>
|
||||||
:credentials="authStore.userInfo?.credentials || []"
|
|
||||||
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
|
|
||||||
:loading="authStore.isLoading"
|
|
||||||
allow-delete
|
|
||||||
@delete="handleDelete"
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<SessionList
|
<SessionList
|
||||||
:sessions="sessions"
|
:sessions="sessions"
|
||||||
:terminating-sessions="terminatingSessions"
|
:terminating-sessions="terminatingSessions"
|
||||||
@terminate="terminateSession"
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||||
section-description="Review where you're signed in and end any sessions you no longer recognize."
|
@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">
|
<Modal v-if="showNameDialog" @close="showNameDialog = false">
|
||||||
<h3>Edit Display Name</h3>
|
<h3>Edit Display Name</h3>
|
||||||
<form @submit.prevent="saveName" class="modal-form">
|
<form @submit.prevent="saveName" class="modal-form">
|
||||||
<NameEditForm
|
<NameEditForm
|
||||||
label="Display Name"
|
label="Display Name"
|
||||||
v-model="newName"
|
v-model="newName"
|
||||||
:busy="saving"
|
:busy="saving"
|
||||||
@cancel="showNameDialog = false"
|
@cancel="showNameDialog = false"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<section class="section-block">
|
<section class="section-block">
|
||||||
<div class="button-row logout-row" :class="{ single: !hasMultipleSessions }">
|
<div class="button-row logout-row" :class="{ single: !hasMultipleSessions }">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
@click="history.back()"
|
@click="goBack"
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
<button v-if="!hasMultipleSessions" @click="logoutEverywhere" class="btn-danger logout-button" :disabled="authStore.isLoading">Logout</button>
|
<button v-if="!hasMultipleSessions" @click="logoutEverywhere" class="btn-danger logout-button" :disabled="authStore.isLoading">Logout</button>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<button @click="logout" class="btn-danger logout-button" :disabled="authStore.isLoading">Logout</button>
|
<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>
|
<button @click="logoutEverywhere" class="btn-danger logout-button" :disabled="authStore.isLoading">All</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
|
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
|
||||||
<p class="logout-note" v-else><strong>Logout</strong> this session on {{ currentSessionHost }}, or <strong>All</strong> sessions across all sites and devices for {{ rpName }}. You'll need to log in again with your passkey afterwards.</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>
|
</section>
|
||||||
<RegistrationLinkModal
|
<RegistrationLinkModal
|
||||||
v-if="showRegLink"
|
v-if="showRegLink"
|
||||||
:endpoint="'/auth/api/user/create-link'"
|
:endpoint="'/auth/api/user/create-link'"
|
||||||
:auto-copy="false"
|
:auto-copy="false"
|
||||||
:prefix-copy-with-user-name="false"
|
:prefix-copy-with-user-name="false"
|
||||||
@close="showRegLink = false"
|
@close="showRegLink = false"
|
||||||
@copied="showRegLink = false; authStore.showMessage('Link copied to clipboard!', 'success', 2500)"
|
@copied="showRegLink = false; authStore.showMessage('Link copied to clipboard!', 'success', 2500)"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -102,6 +105,8 @@ import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
||||||
import passkey from '@/utils/passkey'
|
import passkey from '@/utils/passkey'
|
||||||
|
import { goBack } from '@/utils/helpers'
|
||||||
|
import { apiJson } from '@/utils/api'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const updateInterval = ref(null)
|
const updateInterval = ref(null)
|
||||||
@@ -109,6 +114,8 @@ const showNameDialog = ref(false)
|
|||||||
const showRegLink = ref(false)
|
const showRegLink = ref(false)
|
||||||
const newName = ref('')
|
const newName = ref('')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const hoveredCredentialUuid = ref(null)
|
||||||
|
const hoveredSession = ref(null)
|
||||||
|
|
||||||
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
|
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
|
||||||
|
|
||||||
@@ -120,15 +127,15 @@ onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value
|
|||||||
|
|
||||||
const addNewCredential = async () => {
|
const addNewCredential = async () => {
|
||||||
try {
|
try {
|
||||||
authStore.isLoading = true
|
await passkey.register(null, null, () => {
|
||||||
authStore.showMessage('Adding new passkey...', 'info')
|
authStore.showMessage('Adding new passkey...', 'info')
|
||||||
await passkey.register()
|
})
|
||||||
await authStore.loadUserInfo()
|
await authStore.loadUserInfo()
|
||||||
authStore.showMessage('New passkey added successfully!', 'success', 3000)
|
authStore.showMessage('New passkey added successfully!', 'success', 3000)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to add new passkey:', error)
|
console.error('Failed to add new passkey:', error)
|
||||||
authStore.showMessage(error.message, 'error')
|
authStore.showMessage(error.message, 'error')
|
||||||
} finally { authStore.isLoading = false }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (credential) => {
|
const handleDelete = async (credential) => {
|
||||||
@@ -174,9 +181,7 @@ const saveName = async () => {
|
|||||||
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
|
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
|
||||||
try {
|
try {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
const res = await fetch('/auth/api/user/display-name', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name }) })
|
await apiJson('/auth/api/user/display-name', { method: 'PUT', body: { display_name: name } })
|
||||||
const data = await res.json()
|
|
||||||
if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed')
|
|
||||||
showNameDialog.value = false
|
showNameDialog.value = false
|
||||||
await authStore.loadUserInfo()
|
await authStore.loadUserInfo()
|
||||||
authStore.showMessage('Name updated successfully!', 'success', 3000)
|
authStore.showMessage('Name updated successfully!', 'success', 3000)
|
||||||
@@ -196,4 +201,3 @@ const saveName = async () => {
|
|||||||
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
|
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
|
||||||
@media (max-width: 720px) { .logout-button { width: 100%; } }
|
@media (max-width: 720px) { .logout-button { width: 100%; } }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="!inline" 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 class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
|
||||||
<div class="reg-header-row">
|
<div class="reg-header-row">
|
||||||
<h2 id="regTitle" class="reg-title">
|
<h2 id="regTitle" class="reg-title">
|
||||||
@@ -9,13 +9,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="device-link-section">
|
<div class="device-link-section">
|
||||||
<div class="qr-container">
|
<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>
|
<canvas ref="qrCanvas" class="qr-code"></canvas>
|
||||||
<p>{{ displayUrl }}</p>
|
<p>{{ displayUrl }}</p>
|
||||||
</a>
|
</a>
|
||||||
<div v-else>
|
|
||||||
<em>Generating link...</em>
|
|
||||||
</div>
|
|
||||||
<p class="reg-help">
|
<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-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>
|
<span v-else>Open or scan this link on the device you wish to register to your account.</span>
|
||||||
@@ -25,11 +22,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="reg-actions">
|
<div class="reg-actions">
|
||||||
<button class="btn-secondary" @click="$emit('close')">Close</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="registration-inline-wrapper">
|
<div v-else-if="inline && url" class="registration-inline-wrapper">
|
||||||
<div class="registration-inline-block section-block">
|
<div class="registration-inline-block section-block">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2 class="inline-heading">📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span></h2>
|
<h2 class="inline-heading">📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span></h2>
|
||||||
@@ -37,13 +34,10 @@
|
|||||||
<div class="section-body">
|
<div class="section-body">
|
||||||
<div class="device-link-section">
|
<div class="device-link-section">
|
||||||
<div class="qr-container">
|
<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>
|
<canvas ref="qrCanvas" class="qr-code"></canvas>
|
||||||
<p>{{ displayUrl }}</p>
|
<p>{{ displayUrl }}</p>
|
||||||
</a>
|
</a>
|
||||||
<div v-else>
|
|
||||||
<em>Generating link...</em>
|
|
||||||
</div>
|
|
||||||
<p class="reg-help">
|
<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-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>
|
<span v-else>Open this link on the device you wish to connect with.</span>
|
||||||
@@ -52,7 +46,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row" style="margin-top:1rem;">
|
<div class="button-row" style="margin-top:1rem;">
|
||||||
<button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button>
|
<button class="btn-primary" @click="copy">Copy Link</button>
|
||||||
<button v-if="showCloseInInline" class="btn-secondary" @click="$emit('close')">Close</button>
|
<button v-if="showCloseInInline" class="btn-secondary" @click="$emit('close')">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,6 +58,10 @@
|
|||||||
import { ref, onMounted, watch, computed, nextTick } from 'vue'
|
import { ref, onMounted, watch, computed, nextTick } from 'vue'
|
||||||
import QRCode from 'qrcode/lib/browser'
|
import QRCode from 'qrcode/lib/browser'
|
||||||
import { formatDate } from '@/utils/helpers'
|
import { formatDate } from '@/utils/helpers'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { apiJson, getUserFriendlyErrorMessage, shouldShowErrorToast } from '@/utils/api'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
endpoint: { type: String, required: true },
|
endpoint: { type: String, required: true },
|
||||||
@@ -89,9 +87,7 @@ const expirationMessage = computed(() => {
|
|||||||
|
|
||||||
async function fetchLink() {
|
async function fetchLink() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(props.endpoint, { method: 'POST' })
|
const data = await apiJson(props.endpoint, { method: 'POST' })
|
||||||
const data = await res.json()
|
|
||||||
if (data.detail) throw new Error(data.detail)
|
|
||||||
url.value = data.url
|
url.value = data.url
|
||||||
expires.value = data.expires
|
expires.value = data.expires
|
||||||
emit('generated', { url: data.url, expires: data.expires })
|
emit('generated', { url: data.url, expires: data.expires })
|
||||||
@@ -99,9 +95,12 @@ async function fetchLink() {
|
|||||||
drawQR()
|
drawQR()
|
||||||
if (props.autoCopy) copy()
|
if (props.autoCopy) copy()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
url.value = null
|
|
||||||
expires.value = null
|
|
||||||
console.error('Failed to create link', e)
|
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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -7,33 +7,45 @@
|
|||||||
<div class="section-body">
|
<div class="section-body">
|
||||||
<div :class="['session-list']">
|
<div :class="['session-list']">
|
||||||
<template v-if="Array.isArray(sessions) && sessions.length">
|
<template v-if="Array.isArray(sessions) && sessions.length">
|
||||||
<div
|
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group">
|
||||||
v-for="session in sessions"
|
<h3 :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
|
||||||
:key="session.id"
|
<template v-if="host"><a :href="hostUrl(host)">🌐 {{ host }}</a></template>
|
||||||
:class="['session-item', { 'is-current': session.is_current }]"
|
<template v-else>🌐 Unbound host</template>
|
||||||
>
|
</h3>
|
||||||
<div class="item-top">
|
<div class="session-group-sessions">
|
||||||
<div class="item-icon">
|
<div
|
||||||
<span class="session-emoji">🌐</span>
|
v-for="session in group.sessions"
|
||||||
</div>
|
:key="session.id"
|
||||||
<h4 class="item-title">{{ sessionHostLabel(session) }}</h4>
|
:class="['session-item', {
|
||||||
<div class="item-actions">
|
'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
|
||||||
<span v-if="session.is_current" class="badge badge-current">Current</span>
|
'is-hovered': hoveredSession?.id === session.id,
|
||||||
<span v-else-if="session.is_current_host" class="badge">This host</span>
|
'is-linked-credential': hoveredCredentialUuid === session.credential_uuid
|
||||||
<button
|
}]"
|
||||||
v-if="allowTerminate"
|
tabindex="0"
|
||||||
@click="$emit('terminate', session)"
|
@focusin="handleSessionFocus(session)"
|
||||||
class="btn-card-delete"
|
@focusout="handleSessionBlur($event)"
|
||||||
:disabled="isTerminating(session.id)"
|
>
|
||||||
:title="isTerminating(session.id) ? 'Terminating...' : 'Terminate session'"
|
<div class="item-top">
|
||||||
>🗑️</button>
|
<h4 class="item-title">{{ session.user_agent }}</h4>
|
||||||
</div>
|
<div class="item-actions">
|
||||||
</div>
|
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
|
||||||
<div class="item-details">
|
<span v-else-if="hoveredSession?.id === session.id" class="badge badge-current">Selected</span>
|
||||||
<div class="session-dates">
|
<span v-else-if="hoveredCredentialUuid === session.credential_uuid" class="badge badge-current">Linked</span>
|
||||||
<span class="date-label">Last used:</span>
|
<span v-else-if="!hoveredCredentialUuid && isSameNetwork(session.ip)" class="badge">Same IP</span>
|
||||||
<span class="date-value">{{ formatDate(session.last_renewed) }}</span>
|
<button
|
||||||
<span class="session-meta-info">{{ session.user_agent }} {{ session.ip }}</span>
|
@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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,29 +57,150 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { formatDate } from '@/utils/helpers'
|
import { formatDate } from '@/utils/helpers'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
sessions: { type: Array, default: () => [] },
|
sessions: { type: Array, default: () => [] },
|
||||||
allowTerminate: { type: Boolean, default: true },
|
|
||||||
emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
|
emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
|
||||||
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
|
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
|
||||||
terminatingSessions: { type: Object, default: () => ({}) }
|
terminatingSessions: { type: Object, default: () => ({}) },
|
||||||
|
hoveredCredentialUuid: { type: String, default: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['terminate'])
|
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 isTerminating = (sessionId) => !!props.terminatingSessions[sessionId]
|
||||||
|
|
||||||
const sessionHostLabel = (session) => {
|
const hostUrl = (host) => {
|
||||||
if (!session || !session.host) return 'Unbound host'
|
// Assume http if there's a port number, https otherwise
|
||||||
return session.host
|
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.session-meta-info {
|
.session-meta-info {
|
||||||
grid-column: span 2;
|
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>
|
</style>
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="app-shell">
|
|
||||||
<StatusMessage />
|
|
||||||
<main class="view-root host-view">
|
|
||||||
<div class="view-content">
|
|
||||||
<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="history.back()"
|
|
||||||
>
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, onMounted, ref } from 'vue'
|
|
||||||
import StatusMessage from '@/components/StatusMessage.vue'
|
|
||||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const initializing = ref(true)
|
|
||||||
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(() => {
|
|
||||||
const service = authStore.settings?.rp_name || 'this service'
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
await authStore.loadSettings()
|
|
||||||
const service = authStore.settings?.rp_name
|
|
||||||
if (service) document.title = `${service} · Account summary`
|
|
||||||
await authStore.loadUserInfo()
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unable to load session details'
|
|
||||||
authStore.showMessage(message, 'error', 4000)
|
|
||||||
} finally {
|
|
||||||
initializing.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</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); }
|
|
||||||
.link { color: var(--color-accent); text-decoration: none; }
|
|
||||||
.link:hover { text-decoration: underline; }
|
|
||||||
.view-hint { margin-top: 0.5rem; 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>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import '@/assets/style.css'
|
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
import HostApp from './HostApp.vue'
|
|
||||||
|
|
||||||
const app = createApp(HostApp)
|
|
||||||
|
|
||||||
app.use(createPinia())
|
|
||||||
|
|
||||||
app.mount('#app')
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
<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="view-content">
|
|
||||||
<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">
|
|
||||||
<button class="btn-secondary" :disabled="loading" @click="backNav">Back</button>
|
|
||||||
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser">
|
|
||||||
{{ loading ? 'Signing in…' : 'Login' }}
|
|
||||||
</button>
|
|
||||||
<button v-if="isAuthenticated" class="btn-danger" :disabled="loading" @click="logoutUser">Logout</button>
|
|
||||||
<button v-if="isAuthenticated" class="btn-primary" :disabled="loading" @click="returnHome">Profile</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
|
||||||
import passkey from '@/utils/passkey'
|
|
||||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
|
||||||
|
|
||||||
const status = reactive({ show: false, message: '', type: 'info' })
|
|
||||||
const initializing = ref(true)
|
|
||||||
const loading = ref(false)
|
|
||||||
const settings = ref(null)
|
|
||||||
const userInfo = ref(null)
|
|
||||||
let statusTimer = null
|
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
|
|
||||||
const canAuthenticate = computed(() => !initializing.value && !isAuthenticated.value)
|
|
||||||
const basePath = computed(() => uiBasePath())
|
|
||||||
|
|
||||||
const headingTitle = computed(() => {
|
|
||||||
if (!isAuthenticated.value) return `🔐 ${settings.value?.rp_name || location.origin}`
|
|
||||||
return '🚫 Forbidden'
|
|
||||||
})
|
|
||||||
|
|
||||||
const headerMessage = computed(() => {
|
|
||||||
if (!isAuthenticated.value) return 'Please sign in to access this page.'
|
|
||||||
return 'You lack the permissions required to access this page.'
|
|
||||||
})
|
|
||||||
|
|
||||||
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) document.title = isAuthenticated.value ? `${data.rp_name} · Forbidden` : `${data.rp_name} · Sign In`
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Unable to load settings', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchUserInfo() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/auth/api/user-info', { method: 'POST' })
|
|
||||||
console.log("fetchUserInfo response:", res); // Debug log
|
|
||||||
if (!res.ok) {
|
|
||||||
const payload = await safeParseJson(res)
|
|
||||||
showMessage(payload.detail || 'Unable to load user session info.', 'error', 2000)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userInfo.value = await res.json()
|
|
||||||
// If the user is authenticated but still here, they lack permissions.
|
|
||||||
if (isAuthenticated.value) showMessage('Permission Denied', 'error', 2000)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load user info', error)
|
|
||||||
showMessage('Could not contact the authentication server', 'error', 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(cancelled ? message : `Authentication failed: ${message}`, cancelled ? 'info' : 'error', 4000)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try { await setSessionCookie(result.session_token) } catch (error) {
|
|
||||||
loading.value = false
|
|
||||||
const message = error?.message || 'Failed to establish session'
|
|
||||||
showMessage(message, 'error', 4000)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
location.reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logoutUser() {
|
|
||||||
if (loading.value) return
|
|
||||||
loading.value = true
|
|
||||||
try { await fetch('/auth/api/logout', { method: 'POST' }) } catch (_) { /* ignore */ }
|
|
||||||
finally { loading.value = false; window.location.reload() }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setSessionCookie(sessionToken) {
|
|
||||||
const response = await fetch('/auth/api/set-session', {
|
|
||||||
method: 'POST', headers: { Authorization: `Bearer ${sessionToken}` }
|
|
||||||
})
|
|
||||||
const payload = await safeParseJson(response)
|
|
||||||
if (!response.ok || payload?.detail) throw new Error(payload?.detail || 'Session could not be established.')
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
function returnHome() {
|
|
||||||
const target = basePath.value || '/auth/'
|
|
||||||
if (window.location.pathname !== target) history.replaceState(null, '', target)
|
|
||||||
window.location.href = target
|
|
||||||
}
|
|
||||||
|
|
||||||
function backNav() {
|
|
||||||
try {
|
|
||||||
if (history.length > 1) {
|
|
||||||
history.back()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (_) { /* ignore */ }
|
|
||||||
returnHome()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function safeParseJson(response) { try { return await response.json() } catch (_) { return null } }
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await fetchSettings()
|
|
||||||
await fetchUserInfo()
|
|
||||||
initializing.value = false
|
|
||||||
})
|
|
||||||
</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; display: flex; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
|
||||||
main.view-root .view-content { width: 100%; }
|
|
||||||
.surface.surface--tight {
|
|
||||||
max-width: 520px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.75rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { createApp } from 'vue'
|
|
||||||
import RestrictedApp from './RestrictedApp.vue'
|
|
||||||
import '@/assets/style.css'
|
|
||||||
|
|
||||||
createApp(RestrictedApp).mount('#app')
|
|
||||||
+33
-66
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { register, authenticate } from '@/utils/passkey'
|
import { register, authenticate } from '@/utils/passkey'
|
||||||
import { getSettings } from '@/utils/settings'
|
import { getSettings } from '@/utils/settings'
|
||||||
|
import { apiJson } from '@/utils/api'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', {
|
export const useAuthStore = defineStore('auth', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
@@ -37,22 +38,21 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
}, duration)
|
}, duration)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async setSessionCookie(sessionToken) {
|
async setSessionCookie(result) {
|
||||||
const response = await fetch('/auth/api/set-session', {
|
if (!result?.session_token) {
|
||||||
method: 'POST',
|
console.error('setSessionCookie called with missing session_token:', result)
|
||||||
headers: {'Authorization': `Bearer ${sessionToken}`},
|
throw new Error('Authentication response missing session_token')
|
||||||
})
|
|
||||||
const result = await response.json()
|
|
||||||
if (result.detail) {
|
|
||||||
throw new Error(result.detail)
|
|
||||||
}
|
}
|
||||||
return result
|
return await apiJson('/auth/api/set-session', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Authorization': `Bearer ${result.session_token}`},
|
||||||
|
})
|
||||||
},
|
},
|
||||||
async register() {
|
async register() {
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
try {
|
try {
|
||||||
const result = await register()
|
const result = await register()
|
||||||
await this.setSessionCookie(result.session_token)
|
await this.setSessionCookie(result)
|
||||||
await this.loadUserInfo()
|
await this.loadUserInfo()
|
||||||
this.selectView()
|
this.selectView()
|
||||||
return result
|
return result
|
||||||
@@ -65,7 +65,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
try {
|
try {
|
||||||
const result = await authenticate()
|
const result = await authenticate()
|
||||||
|
|
||||||
await this.setSessionCookie(result.session_token)
|
await this.setSessionCookie(result)
|
||||||
await this.loadUserInfo()
|
await this.loadUserInfo()
|
||||||
this.selectView()
|
this.selectView()
|
||||||
|
|
||||||
@@ -82,45 +82,26 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
this.settings = await getSettings()
|
this.settings = await getSettings()
|
||||||
},
|
},
|
||||||
async loadUserInfo() {
|
async loadUserInfo() {
|
||||||
const response = await fetch('/auth/api/user-info', { method: 'POST' })
|
|
||||||
let result = null
|
|
||||||
try {
|
try {
|
||||||
result = await response.json()
|
this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
|
||||||
} catch (_) {
|
console.log('User info loaded:', this.userInfo)
|
||||||
// ignore JSON parse errors (unlikely)
|
} 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)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
async deleteCredential(uuid) {
|
async deleteCredential(uuid) {
|
||||||
const response = await fetch(`/auth/api/user/credential/${uuid}`, {method: 'Delete'})
|
await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
|
||||||
const result = await response.json()
|
|
||||||
if (result.detail) throw new Error(`Server: ${result.detail}`)
|
|
||||||
|
|
||||||
await this.loadUserInfo()
|
await this.loadUserInfo()
|
||||||
},
|
},
|
||||||
async terminateSession(sessionId) {
|
async terminateSession(sessionId) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' })
|
const payload = await apiJson(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' })
|
||||||
let payload = null
|
|
||||||
try {
|
|
||||||
payload = await res.json()
|
|
||||||
} catch (_) {
|
|
||||||
// ignore JSON parse errors
|
|
||||||
}
|
|
||||||
if (!res.ok || payload?.detail) {
|
|
||||||
const message = payload?.detail || 'Failed to terminate session'
|
|
||||||
throw new Error(message)
|
|
||||||
}
|
|
||||||
if (payload?.current_session_terminated) {
|
if (payload?.current_session_terminated) {
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
location.reload()
|
location.reload()
|
||||||
@@ -135,42 +116,28 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/auth/api/logout', {method: 'POST'})
|
await apiJson('/auth/api/logout', {method: 'POST'})
|
||||||
if (!res.ok) {
|
|
||||||
let message = 'Logout failed'
|
|
||||||
try {
|
|
||||||
const data = await res.json()
|
|
||||||
if (data?.detail) message = data.detail
|
|
||||||
} catch (_) {
|
|
||||||
// ignore JSON parse errors
|
|
||||||
}
|
|
||||||
throw new Error(message)
|
|
||||||
}
|
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
location.reload()
|
location.reload()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout error:', 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() {
|
async logoutEverywhere() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/auth/api/user/logout-all', {method: 'POST'})
|
await apiJson('/auth/api/user/logout-all', {method: 'POST'})
|
||||||
if (!res.ok) {
|
|
||||||
let message = 'Logout failed'
|
|
||||||
try {
|
|
||||||
const data = await res.json()
|
|
||||||
if (data?.detail) message = data.detail
|
|
||||||
} catch (_) {
|
|
||||||
// ignore JSON parse errors
|
|
||||||
}
|
|
||||||
throw new Error(message)
|
|
||||||
}
|
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
location.reload()
|
location.reload()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout-all error:', error)
|
console.error('Logout-all 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')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
#opened = false
|
||||||
|
|
||||||
constructor(resolve, reject, url, protocols, binaryType) {
|
constructor(resolve, reject, url, protocols, binaryType) {
|
||||||
// Support relative URLs even on old browsers that don't
|
// Support relative URLs even on old browsers that don't natively support them
|
||||||
super(new URL(url, location.href.replace(/^http/, 'ws')), protocols)
|
super(new URL(url, document.baseURI.replace(/^http/, 'ws')), protocols)
|
||||||
this.binaryType = binaryType || 'blob'
|
this.binaryType = binaryType || 'blob'
|
||||||
this.onopen = () => {
|
this.onopen = () => {
|
||||||
this.#opened = true
|
this.#opened = true
|
||||||
@@ -51,17 +51,12 @@ class AwaitableWebSocket extends WebSocket {
|
|||||||
console.error("WebSocket received binary data, expected JSON string", data)
|
console.error("WebSocket received binary data, expected JSON string", data)
|
||||||
throw new Error("WebSocket received binary data, expected JSON string")
|
throw new Error("WebSocket received binary data, expected JSON string")
|
||||||
}
|
}
|
||||||
let parsed
|
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(data)
|
return JSON.parse(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to parse JSON from WebSocket message", data, err)
|
console.error("Failed to parse JSON from WebSocket message", data, err)
|
||||||
throw new Error("Failed to parse JSON from WebSocket message")
|
throw new Error("Failed to parse JSON from WebSocket message")
|
||||||
}
|
}
|
||||||
if (parsed.detail) {
|
|
||||||
throw new Error(`Server: ${parsed.detail}`)
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
send_json(data) {
|
send_json(data) {
|
||||||
|
|||||||
@@ -24,3 +24,5 @@ export function getCookie(name) {
|
|||||||
const parts = value.split(`; ${name}=`)
|
const parts = value.split(`; ${name}=`)
|
||||||
if (parts.length === 2) return parts.pop().split(';').shift()
|
if (parts.length === 2) return parts.pop().split(';').shift()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const goBack = () => history.back() || window.close()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
||||||
import aWebSocket from '@/utils/awaitable-websocket'
|
import aWebSocket from '@/utils/awaitable-websocket'
|
||||||
import { getSettings } from '@/utils/settings'
|
import { getSettings } from '@/utils/settings'
|
||||||
|
import { showAuthIframe } from '@/utils/api'
|
||||||
|
|
||||||
// Generic path normalizer: if an auth_host is configured and differs from current
|
// Generic path normalizer: if an auth_host is configured and differs from current
|
||||||
// host, return absolute URL (scheme derived by aWebSocket). Otherwise, keep as-is.
|
// host, return absolute URL (scheme derived by aWebSocket). Otherwise, keep as-is.
|
||||||
@@ -10,33 +11,66 @@ async function makeUrl(path) {
|
|||||||
return h && location.host !== h ? `//${h}${path}` : path
|
return h && location.host !== h ? `//${h}${path}` : path
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function register(resetToken = null, displayName = null) {
|
export async function register(resetToken = null, displayName = null, onstartreg = null) {
|
||||||
let params = []
|
let params = []
|
||||||
if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`)
|
if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`)
|
||||||
if (displayName) params.push(`name=${encodeURIComponent(displayName)}`)
|
if (displayName) params.push(`name=${encodeURIComponent(displayName)}`)
|
||||||
const qs = params.length ? `?${params.join('&')}` : ''
|
const qs = params.length ? `?${params.join('&')}` : ''
|
||||||
const ws = await aWebSocket(await makeUrl(`/auth/ws/register${qs}`))
|
|
||||||
try {
|
while (true) {
|
||||||
const optionsJSON = await ws.receive_json()
|
const ws = await aWebSocket(await makeUrl(`/auth/ws/register${qs}`))
|
||||||
const registrationResponse = await startRegistration({ optionsJSON })
|
try {
|
||||||
ws.send_json(registrationResponse)
|
const res = await ws.receive_json()
|
||||||
return await ws.receive_json()
|
|
||||||
} catch (error) {
|
// Handle auth errors (401/403) with iframe
|
||||||
console.error('Registration error:', error)
|
if ((res.status === 401 || res.status === 403) && res.auth?.iframe) {
|
||||||
// Replace useless and ugly error message from startRegistration
|
ws.close()
|
||||||
throw Error(error.name === "NotAllowedError" ? 'Passkey registration cancelled' : error.message)
|
await showAuthIframe(res.auth.iframe)
|
||||||
} finally {
|
continue
|
||||||
ws.close()
|
}
|
||||||
|
|
||||||
|
// 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() {
|
export async function authenticate() {
|
||||||
const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate'))
|
const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate'))
|
||||||
try {
|
try {
|
||||||
const optionsJSON = await ws.receive_json()
|
const res = await ws.receive_json()
|
||||||
const authResponse = await startAuthentication({ optionsJSON })
|
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)
|
ws.send_json(authResponse)
|
||||||
|
|
||||||
const result = await ws.receive_json()
|
const result = await ws.receive_json()
|
||||||
|
if (result.status) {
|
||||||
|
throw new Error(result.detail || `Authentication failed: ${result.status}`)
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Authentication error:', error)
|
console.error('Authentication error:', error)
|
||||||
|
|||||||
+69
-38
@@ -1,64 +1,95 @@
|
|||||||
import { fileURLToPath, URL } from 'node:url'
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { existsSync, renameSync, mkdirSync } from 'node:fs'
|
||||||
|
import sirv from 'sirv'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
export default defineConfig(({ command }) => ({
|
||||||
export default defineConfig(({ command, mode }) => ({
|
appType: 'mpa',
|
||||||
|
publicDir: 'public',
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
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: {
|
resolve: {
|
||||||
alias: {
|
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
// Use absolute paths at dev, deploy under /auth/
|
base: '/',
|
||||||
base: command === 'build' ? '/auth/' : '/',
|
|
||||||
server: {
|
server: {
|
||||||
port: 4403,
|
port: 4403,
|
||||||
|
fs: {
|
||||||
|
allow: ['..']
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
'/auth/': {
|
// Only proxy these two specific backend API paths
|
||||||
|
'/auth/api': {
|
||||||
target: 'http://localhost:4402',
|
target: 'http://localhost:4402',
|
||||||
ws: true,
|
headers: { connection: 'close' }
|
||||||
changeOrigin: false,
|
},
|
||||||
// We proxy API + WS under /auth/, but want Vite to serve the SPA entrypoints
|
'/auth/ws': {
|
||||||
// and static assets so that HMR works. Bypass tells http-proxy to skip
|
target: 'http://localhost:4402',
|
||||||
// proxying when we return a (possibly rewritten) local path.
|
ws: true
|
||||||
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/host' || url === '/auth/host/') return '/host/index.html'
|
|
||||||
if (url === '/host' || url === '/host/') return '/host/index.html'
|
|
||||||
if (url === '/auth/admin' || url === '/auth/admin/') return '/admin/'
|
|
||||||
if (url.startsWith('/auth/assets/')) return url.replace(/^\/auth/, '')
|
|
||||||
if (/^\/auth\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html'
|
|
||||||
if (/^\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html'
|
|
||||||
if (url === '/auth/restricted' || url === '/auth/restricted/') return '/restricted/index.html'
|
|
||||||
if (url === '/restricted' || url === '/restricted/') return '/restricted/index.html'
|
|
||||||
// Everything else (including /auth/admin/* APIs) should proxy.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: '../passkey/frontend-build',
|
outDir: '../passkey/frontend-build',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
assetsDir: 'assets',
|
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
index: resolve(__dirname, 'index.html'),
|
auth: resolve(__dirname, 'auth/index.html'),
|
||||||
admin: resolve(__dirname, 'admin/index.html'),
|
admin: resolve(__dirname, 'auth/admin/index.html'),
|
||||||
reset: resolve(__dirname, 'reset/index.html'),
|
restricted: resolve(__dirname, 'auth/restricted/index.html'),
|
||||||
restricted: resolve(__dirname, 'restricted/index.html'),
|
reset: resolve(__dirname, 'int/reset/index.html'),
|
||||||
host: resolve(__dirname, 'host/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]'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|||||||
+14
-24
@@ -69,36 +69,26 @@ async def create_session(
|
|||||||
async def get_reset(token: str) -> ResetToken:
|
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)."""
|
"""Validate a credential reset token. Returns None if the token is not well formed (i.e. it is another type of token)."""
|
||||||
record = await db.instance.get_reset_token(reset_key(token))
|
record = await db.instance.get_reset_token(reset_key(token))
|
||||||
if not record:
|
if record and record.expiry >= datetime.now(timezone.utc):
|
||||||
raise ValueError("Invalid or expired session token")
|
return record
|
||||||
if record.expiry < datetime.now(timezone.utc):
|
raise ValueError("This reset link is invalid or has expired")
|
||||||
await db.instance.delete_reset_token(record.key)
|
|
||||||
raise ValueError("Invalid or expired session token")
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
async def get_session(token: str, host: str | None = None) -> Session:
|
async def get_session(token: str, host: str | None = None) -> Session:
|
||||||
"""Validate a session token and return session data if valid."""
|
"""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))
|
session = await db.instance.get_session(session_key(token))
|
||||||
if not session:
|
if session and session_expiry(session) >= datetime.now(timezone.utc):
|
||||||
raise ValueError("Invalid or expired session token")
|
if session.host is None:
|
||||||
if session_expiry(session) < datetime.now(timezone.utc):
|
|
||||||
await db.instance.delete_session(session.key)
|
|
||||||
raise ValueError("Invalid or expired session token")
|
|
||||||
if host is not None:
|
|
||||||
normalized_host = hostutil.normalize_host(host)
|
|
||||||
if not normalized_host:
|
|
||||||
raise ValueError("Invalid host")
|
|
||||||
current = session.host
|
|
||||||
if current is None:
|
|
||||||
# First time binding: store exact host:port (or IPv6 form) now.
|
# First time binding: store exact host:port (or IPv6 form) now.
|
||||||
await db.instance.set_session_host(session.key, normalized_host)
|
await db.instance.set_session_host(session.key, host)
|
||||||
session.host = normalized_host
|
session.host = host
|
||||||
elif current == normalized_host:
|
elif session.host != host:
|
||||||
pass # exact match ok
|
raise ValueError("Session host mismatch")
|
||||||
else:
|
return session
|
||||||
raise ValueError("Invalid or expired session token")
|
raise ValueError("Your session has expired. Please sign in again!")
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
async def refresh_session_token(token: str, *, ip: str, user_agent: str):
|
async def refresh_session_token(token: str, *, ip: str, user_agent: str):
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ class SessionContext:
|
|||||||
user: User
|
user: User
|
||||||
org: Org
|
org: Org
|
||||||
role: Role
|
role: Role
|
||||||
|
credential: Credential | None = None
|
||||||
permissions: list[Permission] | None = None
|
permissions: list[Permission] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+17
-4
@@ -439,7 +439,7 @@ class DB(DatabaseInterface):
|
|||||||
credential_model = result.scalar_one_or_none()
|
credential_model = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not credential_model:
|
if not credential_model:
|
||||||
raise ValueError("Credential not registered")
|
raise ValueError("Credential not found")
|
||||||
return Credential(
|
return Credential(
|
||||||
uuid=UUID(bytes=credential_model.uuid),
|
uuid=UUID(bytes=credential_model.uuid),
|
||||||
credential_id=credential_model.credential_id,
|
credential_id=credential_model.credential_id,
|
||||||
@@ -1314,19 +1314,24 @@ class DB(DatabaseInterface):
|
|||||||
Uses efficient JOINs to retrieve all related data in a single database query.
|
Uses efficient JOINs to retrieve all related data in a single database query.
|
||||||
"""
|
"""
|
||||||
async with self.session() as session:
|
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 = (
|
stmt = (
|
||||||
select(
|
select(
|
||||||
SessionModel,
|
SessionModel,
|
||||||
UserModel,
|
UserModel,
|
||||||
RoleModel,
|
RoleModel,
|
||||||
OrgModel,
|
OrgModel,
|
||||||
|
CredentialModel,
|
||||||
PermissionModel,
|
PermissionModel,
|
||||||
)
|
)
|
||||||
.select_from(SessionModel)
|
.select_from(SessionModel)
|
||||||
.join(UserModel, SessionModel.user_uuid == UserModel.uuid)
|
.join(UserModel, SessionModel.user_uuid == UserModel.uuid)
|
||||||
.join(RoleModel, UserModel.role_uuid == RoleModel.uuid)
|
.join(RoleModel, UserModel.role_uuid == RoleModel.uuid)
|
||||||
.join(OrgModel, RoleModel.org_uuid == OrgModel.uuid)
|
.join(OrgModel, RoleModel.org_uuid == OrgModel.uuid)
|
||||||
|
.outerjoin(
|
||||||
|
CredentialModel,
|
||||||
|
SessionModel.credential_uuid == CredentialModel.uuid,
|
||||||
|
)
|
||||||
.outerjoin(RolePermission, RoleModel.uuid == RolePermission.role_uuid)
|
.outerjoin(RolePermission, RoleModel.uuid == RolePermission.role_uuid)
|
||||||
.outerjoin(
|
.outerjoin(
|
||||||
PermissionModel, RolePermission.permission_id == PermissionModel.id
|
PermissionModel, RolePermission.permission_id == PermissionModel.id
|
||||||
@@ -1342,7 +1347,9 @@ class DB(DatabaseInterface):
|
|||||||
|
|
||||||
# Extract the first row to get session and user data
|
# Extract the first row to get session and user data
|
||||||
first_row = rows[0]
|
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
|
# Create the session object
|
||||||
if host is not None:
|
if host is not None:
|
||||||
@@ -1371,11 +1378,16 @@ class DB(DatabaseInterface):
|
|||||||
display_name=role_model.display_name,
|
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
|
# Collect all unique permissions for the role
|
||||||
permissions = []
|
permissions = []
|
||||||
seen_permission_ids = set()
|
seen_permission_ids = set()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
_, _, _, _, permission_model = row
|
_, _, _, _, _, permission_model = row
|
||||||
if permission_model and permission_model.id not in seen_permission_ids:
|
if permission_model and permission_model.id not in seen_permission_ids:
|
||||||
permissions.append(
|
permissions.append(
|
||||||
Permission(
|
Permission(
|
||||||
@@ -1405,5 +1417,6 @@ class DB(DatabaseInterface):
|
|||||||
user=user_obj,
|
user=user_obj,
|
||||||
org=organization,
|
org=organization,
|
||||||
role=role,
|
role=role,
|
||||||
|
credential=credential_obj,
|
||||||
permissions=effective_permissions if effective_permissions else None,
|
permissions=effective_permissions if effective_permissions else None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ def main():
|
|||||||
if args.command in {"serve", "dev"}:
|
if args.command in {"serve", "dev"}:
|
||||||
run_kwargs: dict = {
|
run_kwargs: dict = {
|
||||||
"reload": devmode,
|
"reload": devmode,
|
||||||
|
"reload_dirs": ["passkey"] if devmode else None,
|
||||||
"log_level": "info",
|
"log_level": "info",
|
||||||
}
|
}
|
||||||
if uds:
|
if uds:
|
||||||
@@ -243,9 +244,8 @@ def main():
|
|||||||
run_kwargs["port"] = port
|
run_kwargs["port"] = port
|
||||||
|
|
||||||
if devmode:
|
if devmode:
|
||||||
if os.environ.get("PASSKEY_BUN_PARENT") != "1":
|
os.environ["PASSKEY_DEVMODE"] = "1"
|
||||||
os.environ["PASSKEY_BUN_PARENT"] = "1"
|
frontend.run_dev()
|
||||||
frontend.run_dev()
|
|
||||||
|
|
||||||
if all_ifaces and not uds:
|
if all_ifaces and not uds:
|
||||||
if devmode:
|
if devmode:
|
||||||
|
|||||||
+84
-24
@@ -2,8 +2,8 @@ import logging
|
|||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import Body, FastAPI, HTTPException, Request
|
from fastapi import Body, FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from ..authsession import reset_expires
|
from ..authsession import reset_expires
|
||||||
from ..globals import db
|
from ..globals import db
|
||||||
@@ -28,6 +28,15 @@ async def value_error_handler(_request, exc: ValueError): # pragma: no cover -
|
|||||||
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
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)
|
@app.exception_handler(Exception)
|
||||||
async def general_exception_handler(_request, exc: Exception):
|
async def general_exception_handler(_request, exc: Exception):
|
||||||
logging.exception("Unhandled exception in admin app")
|
logging.exception("Unhandled exception in admin app")
|
||||||
@@ -36,22 +45,7 @@ async def general_exception_handler(_request, exc: Exception):
|
|||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def adminapp(request: Request, auth=AUTH_COOKIE):
|
async def adminapp(request: Request, auth=AUTH_COOKIE):
|
||||||
"""Serve admin SPA only for authenticated users with admin/org permissions.
|
return Response(*await frontend.read("/auth/admin/index.html"))
|
||||||
|
|
||||||
On missing/invalid session or insufficient permissions, serve restricted SPA.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await authz.verify(
|
|
||||||
auth,
|
|
||||||
["auth:admin", "auth:org:*"],
|
|
||||||
match=permutil.has_any,
|
|
||||||
host=request.headers.get("host"),
|
|
||||||
)
|
|
||||||
return FileResponse(frontend.file("admin/index.html"))
|
|
||||||
except HTTPException as e:
|
|
||||||
return FileResponse(
|
|
||||||
frontend.file("restricted", "index.html"), status_code=e.status_code
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------- Organizations --------------------
|
# -------------------- Organizations --------------------
|
||||||
@@ -172,6 +166,7 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
|
|||||||
["auth:admin", f"auth:org:{org_uuid}"],
|
["auth:admin", f"auth:org:{org_uuid}"],
|
||||||
match=permutil.has_any,
|
match=permutil.has_any,
|
||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
|
max_age="5m",
|
||||||
)
|
)
|
||||||
if ctx.org.uuid == org_uuid:
|
if ctx.org.uuid == org_uuid:
|
||||||
raise ValueError("Cannot delete the organization you belong to")
|
raise ValueError("Cannot delete the organization you belong to")
|
||||||
@@ -321,6 +316,7 @@ async def admin_delete_role(
|
|||||||
["auth:admin", f"auth:org:{org_uuid}"],
|
["auth:admin", f"auth:org:{org_uuid}"],
|
||||||
match=permutil.has_any,
|
match=permutil.has_any,
|
||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
|
max_age="5m",
|
||||||
)
|
)
|
||||||
role = await db.instance.get_role(role_uuid)
|
role = await db.instance.get_role(role_uuid)
|
||||||
if role.org_uuid != org_uuid:
|
if role.org_uuid != org_uuid:
|
||||||
@@ -434,12 +430,15 @@ async def admin_create_user_registration_link(
|
|||||||
["auth:admin", f"auth:org:{org_uuid}"],
|
["auth:admin", f"auth:org:{org_uuid}"],
|
||||||
match=permutil.has_any,
|
match=permutil.has_any,
|
||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
|
max_age="5m",
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
"auth:admin" not in ctx.role.permissions
|
"auth:admin" not in ctx.role.permissions
|
||||||
and f"auth:org:{org_uuid}" 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
|
# Check if user has existing credentials
|
||||||
credentials = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
credentials = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||||
@@ -489,7 +488,9 @@ async def admin_get_user_detail(
|
|||||||
"auth:admin" not in ctx.role.permissions
|
"auth:admin" not in ctx.role.permissions
|
||||||
and f"auth:org:{org_uuid}" 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)
|
user = await db.instance.get_user_by_uuid(user_uuid)
|
||||||
cred_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
cred_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||||
creds: list[dict] = []
|
creds: list[dict] = []
|
||||||
@@ -636,7 +637,9 @@ async def admin_update_user_display_name(
|
|||||||
"auth:admin" not in ctx.role.permissions
|
"auth:admin" not in ctx.role.permissions
|
||||||
and f"auth:org:{org_uuid}" 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()
|
new_name = (payload.get("display_name") or "").strip()
|
||||||
if not new_name:
|
if not new_name:
|
||||||
raise HTTPException(status_code=400, detail="display_name required")
|
raise HTTPException(status_code=400, detail="display_name required")
|
||||||
@@ -665,16 +668,65 @@ async def admin_delete_user_credential(
|
|||||||
["auth:admin", f"auth:org:{org_uuid}"],
|
["auth:admin", f"auth:org:{org_uuid}"],
|
||||||
match=permutil.has_any,
|
match=permutil.has_any,
|
||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
|
max_age="5m",
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
"auth:admin" not in ctx.role.permissions
|
"auth:admin" not in ctx.role.permissions
|
||||||
and f"auth:org:{org_uuid}" 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"
|
||||||
|
)
|
||||||
await db.instance.delete_credential(credential_uuid, user_uuid)
|
await db.instance.delete_credential(credential_uuid, user_uuid)
|
||||||
return {"status": "ok"}
|
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) --------------------
|
# -------------------- Permissions (global) --------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -705,7 +757,11 @@ async def admin_create_permission(
|
|||||||
auth=AUTH_COOKIE,
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
await authz.verify(
|
await authz.verify(
|
||||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
auth,
|
||||||
|
["auth:admin"],
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
match=permutil.has_all,
|
||||||
|
max_age="5m",
|
||||||
)
|
)
|
||||||
from ..db import Permission as PermDC
|
from ..db import Permission as PermDC
|
||||||
|
|
||||||
@@ -777,7 +833,11 @@ async def admin_delete_permission(
|
|||||||
auth=AUTH_COOKIE,
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
await authz.verify(
|
await authz.verify(
|
||||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
auth,
|
||||||
|
["auth:admin"],
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
match=permutil.has_all,
|
||||||
|
max_age="5m",
|
||||||
)
|
)
|
||||||
querysafe.assert_safe(permission_id, field="permission_id")
|
querysafe.assert_safe(permission_id, field="permission_id")
|
||||||
|
|
||||||
|
|||||||
+69
-173
@@ -13,9 +13,8 @@ from fastapi import (
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.security import HTTPBearer
|
from fastapi.security import HTTPBearer
|
||||||
|
|
||||||
from passkey.util import frontend, useragent
|
from passkey.util import frontend
|
||||||
|
|
||||||
from .. import aaguid
|
|
||||||
from ..authsession import (
|
from ..authsession import (
|
||||||
EXPIRES,
|
EXPIRES,
|
||||||
get_reset,
|
get_reset,
|
||||||
@@ -25,8 +24,8 @@ from ..authsession import (
|
|||||||
)
|
)
|
||||||
from ..globals import db
|
from ..globals import db
|
||||||
from ..globals import passkey as global_passkey
|
from ..globals import passkey as global_passkey
|
||||||
from ..util import hostutil, passphrase, permutil
|
from ..util import hostutil, htmlutil, passphrase, userinfo
|
||||||
from ..util.tokens import encode_session_key, session_key
|
from ..util.tokens import session_key
|
||||||
from . import authz, session, user
|
from . import authz, session, user
|
||||||
from .session import AUTH_COOKIE
|
from .session import AUTH_COOKIE
|
||||||
|
|
||||||
@@ -58,6 +57,15 @@ async def value_error_handler(_request: Request, exc: ValueError):
|
|||||||
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
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)
|
@app.exception_handler(Exception)
|
||||||
async def general_exception_handler(_request: Request, exc: Exception):
|
async def general_exception_handler(_request: Request, exc: Exception):
|
||||||
logging.exception("Unhandled exception in API app")
|
logging.exception("Unhandled exception in API app")
|
||||||
@@ -85,7 +93,7 @@ async def validate_token(
|
|||||||
renewed = False
|
renewed = False
|
||||||
if auth:
|
if auth:
|
||||||
current_expiry = session_expiry(ctx.session)
|
current_expiry = session_expiry(ctx.session)
|
||||||
consumed = EXPIRES - (current_expiry - datetime.now())
|
consumed = EXPIRES - (current_expiry - datetime.now(timezone.utc))
|
||||||
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
|
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
|
||||||
try:
|
try:
|
||||||
await refresh_session_token(
|
await refresh_session_token(
|
||||||
@@ -97,7 +105,9 @@ async def validate_token(
|
|||||||
renewed = True
|
renewed = True
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Session disappeared, e.g. due to concurrent logout; global handler will clear
|
# Session disappeared, e.g. due to concurrent logout; global handler will clear
|
||||||
raise HTTPException(status_code=401, detail="Session expired")
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Session expired", mode="login"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"valid": True,
|
"valid": True,
|
||||||
"user_uuid": str(ctx.session.user_uuid),
|
"user_uuid": str(ctx.session.user_uuid),
|
||||||
@@ -110,18 +120,27 @@ async def forward_authentication(
|
|||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
perm: list[str] = Query([]),
|
perm: list[str] = Query([]),
|
||||||
|
max_age: str | None = Query(None),
|
||||||
auth=AUTH_COOKIE,
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
"""Forward auth validation for Caddy/Nginx.
|
"""Forward auth validation for Caddy/Nginx.
|
||||||
|
|
||||||
Query Params:
|
Query Params:
|
||||||
- perm: repeated permission IDs the authenticated user must possess (ALL required).
|
- 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.
|
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:
|
try:
|
||||||
ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
|
ctx = await authz.verify(
|
||||||
|
auth, perm, host=request.headers.get("host"), max_age=max_age
|
||||||
|
)
|
||||||
role_permissions = set(ctx.role.permissions or [])
|
role_permissions = set(ctx.role.permissions or [])
|
||||||
if ctx.permissions:
|
if ctx.permissions:
|
||||||
role_permissions.update(permission.id for permission in ctx.permissions)
|
role_permissions.update(permission.id for permission in ctx.permissions)
|
||||||
@@ -148,14 +167,29 @@ async def forward_authentication(
|
|||||||
"Remote-Credential": str(ctx.session.credential_uuid),
|
"Remote-Credential": str(ctx.session.credential_uuid),
|
||||||
}
|
}
|
||||||
return Response(status_code=204, headers=remote_headers)
|
return Response(status_code=204, headers=remote_headers)
|
||||||
except HTTPException as e:
|
except authz.AuthException as e:
|
||||||
# Let global handler clear cookie; still return HTML surface instead of JSON
|
# Clear cookie only if session is invalid (not for reauth)
|
||||||
html = frontend.file("restricted", "index.html").read_bytes()
|
if e.clear_session:
|
||||||
status = e.status_code
|
|
||||||
# If 401 we still want cookie cleared; rely on handler by raising again not feasible (we need HTML)
|
|
||||||
if status == 401:
|
|
||||||
session.clear_session_cookie(response)
|
session.clear_session_cookie(response)
|
||||||
return Response(html, status_code=status, media_type="text/html")
|
|
||||||
|
# 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")
|
@app.get("/settings")
|
||||||
@@ -177,6 +211,12 @@ async def api_user_info(
|
|||||||
reset: str | None = None,
|
reset: str | None = None,
|
||||||
auth=AUTH_COOKIE,
|
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
|
authenticated = False
|
||||||
session_record = None
|
session_record = None
|
||||||
reset_token = None
|
reset_token = None
|
||||||
@@ -188,175 +228,31 @@ async def api_user_info(
|
|||||||
target_user_uuid = reset_token.user_uuid
|
target_user_uuid = reset_token.user_uuid
|
||||||
else:
|
else:
|
||||||
if auth is None:
|
if auth is None:
|
||||||
raise ValueError("Authentication Required")
|
raise authz.AuthException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Authentication required",
|
||||||
|
mode="login",
|
||||||
|
)
|
||||||
session_record = await get_session(auth, host=request.headers.get("host"))
|
session_record = await get_session(auth, host=request.headers.get("host"))
|
||||||
authenticated = True
|
authenticated = True
|
||||||
target_user_uuid = session_record.user_uuid
|
target_user_uuid = session_record.user_uuid
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(401, str(e))
|
raise HTTPException(401, str(e))
|
||||||
|
|
||||||
u = await db.instance.get_user_by_uuid(target_user_uuid)
|
# Return minimal response for reset tokens
|
||||||
|
if not authenticated and reset_token:
|
||||||
if not authenticated and reset_token: # minimal response for reset tokens
|
return await userinfo.format_reset_user_info(target_user_uuid, reset_token)
|
||||||
return {
|
|
||||||
"authenticated": False,
|
|
||||||
"session_type": reset_token.token_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 auth is not None
|
||||||
assert session_record is not None
|
assert session_record is not None
|
||||||
|
|
||||||
ctx = await permutil.session_context(auth, request.headers.get("host"))
|
return await userinfo.format_user_info(
|
||||||
credential_ids = await db.instance.get_credentials_by_user_uuid(
|
user_uuid=target_user_uuid,
|
||||||
session_record.user_uuid
|
auth=auth,
|
||||||
|
session_record=session_record,
|
||||||
|
request_host=request.headers.get("host"),
|
||||||
)
|
)
|
||||||
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.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,
|
|
||||||
"is_current_session": session_record.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 [])
|
|
||||||
is_org_admin = any(
|
|
||||||
p.startswith("auth:org:") for p in (role_info["permissions"] or [])
|
|
||||||
)
|
|
||||||
|
|
||||||
normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
|
|
||||||
session_records = await db.instance.list_sessions_for_user(session_record.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 {
|
|
||||||
"authenticated": True,
|
|
||||||
"user": {
|
|
||||||
"user_uuid": str(u.uuid),
|
|
||||||
"user_name": u.display_name,
|
|
||||||
"created_at": (
|
|
||||||
u.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
||||||
if u.created_at and u.created_at.tzinfo
|
|
||||||
else (
|
|
||||||
u.created_at.replace(tzinfo=timezone.utc)
|
|
||||||
.isoformat()
|
|
||||||
.replace("+00:00", "Z")
|
|
||||||
if u.created_at
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"last_seen": (
|
|
||||||
u.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
||||||
if u.last_seen and u.last_seen.tzinfo
|
|
||||||
else (
|
|
||||||
u.last_seen.replace(tzinfo=timezone.utc)
|
|
||||||
.isoformat()
|
|
||||||
.replace("+00:00", "Z")
|
|
||||||
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,
|
|
||||||
"sessions": sessions_payload,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/logout")
|
@app.post("/logout")
|
||||||
|
|||||||
@@ -2,31 +2,95 @@ import logging
|
|||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from ..util import permutil
|
from ..util import permutil, sessionutil
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
async def verify(
|
||||||
auth: str | None,
|
auth: str | None,
|
||||||
perm: list[str],
|
perm: list[str],
|
||||||
match=permutil.has_all,
|
match=permutil.has_all,
|
||||||
host: str | None = None,
|
host: str | None = None,
|
||||||
|
max_age: str | None = None,
|
||||||
):
|
):
|
||||||
"""Validate session token and optional list of required permissions.
|
"""Validate session token and optional list of required permissions.
|
||||||
|
|
||||||
Returns the session context.
|
Returns the session context.
|
||||||
|
|
||||||
Raises HTTPException on failure:
|
Raises AuthException on failure with metadata for UI rendering.
|
||||||
401: unauthenticated / invalid session
|
|
||||||
403: required permissions missing
|
|
||||||
"""
|
"""
|
||||||
if not auth:
|
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, host)
|
ctx = await permutil.session_context(auth, host)
|
||||||
if not ctx:
|
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):
|
if not match(ctx, perm):
|
||||||
# Determine which permissions are missing for clearer diagnostics
|
# Determine which permissions are missing for clearer diagnostics
|
||||||
@@ -39,6 +103,8 @@ async def verify(
|
|||||||
perm,
|
perm,
|
||||||
ctx.role.permissions,
|
ctx.role.permissions,
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=403, detail="Permission required")
|
raise AuthException(
|
||||||
|
status_code=403, mode="forbidden", detail="Permission required"
|
||||||
|
)
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|||||||
+27
-35
@@ -3,7 +3,7 @@ import os
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request, Response
|
from fastapi import FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from passkey.util import frontend, hostutil, passphrase
|
from passkey.util import frontend, hostutil, passphrase
|
||||||
@@ -42,6 +42,16 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|||||||
logging.error(f"⚠️ {e}")
|
logging.error(f"⚠️ {e}")
|
||||||
# Re-raise to fail fast
|
# Re-raise to fail fast
|
||||||
raise
|
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
|
yield
|
||||||
# (Optional) add shutdown cleanup here later
|
# (Optional) add shutdown cleanup here later
|
||||||
|
|
||||||
@@ -51,12 +61,16 @@ app = FastAPI(lifespan=lifespan)
|
|||||||
# Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
|
# Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
|
||||||
app.middleware("http")(auth_host.redirect_middleware)
|
app.middleware("http")(auth_host.redirect_middleware)
|
||||||
|
|
||||||
app.mount("/auth/admin/", admin.app)
|
app.mount("/auth/api/admin/", admin.app)
|
||||||
app.mount("/auth/api/", api.app)
|
app.mount("/auth/api/", api.app)
|
||||||
app.mount("/auth/ws/", ws.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
|
# 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
|
# / is used on a dedicated auth site, /auth/ on app domains with auth
|
||||||
@@ -65,29 +79,12 @@ app.mount(
|
|||||||
@app.get("/")
|
@app.get("/")
|
||||||
@app.get("/auth/")
|
@app.get("/auth/")
|
||||||
async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
|
async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||||
"""Serve the user profile SPA only for authenticated sessions; otherwise restricted SPA.
|
"""Serve the user profile app.
|
||||||
|
|
||||||
Login / authentication UX is centralized in the restricted app.
|
The frontend handles mode detection (host mode vs full profile) based on settings.
|
||||||
|
Access control is handled via APIs.
|
||||||
"""
|
"""
|
||||||
if not auth:
|
return Response(*await frontend.read("/auth/index.html"))
|
||||||
return FileResponse(frontend.file("restricted", "index.html"), status_code=401)
|
|
||||||
from ..authsession import get_session # local import
|
|
||||||
|
|
||||||
try:
|
|
||||||
await get_session(auth, host=request.headers.get("host"))
|
|
||||||
cfg_host = hostutil.configured_auth_host()
|
|
||||||
if cfg_host:
|
|
||||||
cur_host = hostutil.normalize_host(request.headers.get("host"))
|
|
||||||
cfg_normalized = hostutil.normalize_host(cfg_host)
|
|
||||||
if cur_host and cfg_normalized and cur_host != cfg_normalized:
|
|
||||||
return FileResponse(frontend.file("host", "index.html"))
|
|
||||||
return FileResponse(frontend.file("index.html"))
|
|
||||||
except Exception:
|
|
||||||
if auth:
|
|
||||||
from . import session as session_mod
|
|
||||||
|
|
||||||
session_mod.clear_session_cookie(response)
|
|
||||||
return FileResponse(frontend.file("restricted", "index.html"), status_code=401)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin", include_in_schema=False)
|
@app.get("/admin", include_in_schema=False)
|
||||||
@@ -98,19 +95,14 @@ async def admin_root_redirect():
|
|||||||
|
|
||||||
@app.get("/admin/", include_in_schema=False)
|
@app.get("/admin/", include_in_schema=False)
|
||||||
async def admin_root(request: Request, auth=AUTH_COOKIE):
|
async def admin_root(request: Request, auth=AUTH_COOKIE):
|
||||||
return await admin.adminapp(request, auth) # Delegated (enforces access control)
|
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("/{reset}")
|
||||||
@app.get("/auth/{reset}")
|
@app.get("/auth/{reset}")
|
||||||
async def reset_link(reset: str):
|
async def reset_link(reset: str):
|
||||||
"""Serve the SPA directly with an injected reset token."""
|
"""Serve the reset app directly with an injected reset token."""
|
||||||
if not passphrase.is_well_formed(reset):
|
if not passphrase.is_well_formed(reset):
|
||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
return FileResponse(frontend.file("reset", "index.html"))
|
return Response(*await frontend.read("/int/reset/index.html"))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/restricted", include_in_schema=False)
|
|
||||||
@app.get("/auth/restricted", include_in_schema=False)
|
|
||||||
async def restricted_view():
|
|
||||||
return FileResponse(frontend.file("restricted", "index.html"))
|
|
||||||
|
|||||||
+36
-8
@@ -8,6 +8,7 @@ from fastapi import (
|
|||||||
Request,
|
Request,
|
||||||
Response,
|
Response,
|
||||||
)
|
)
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from ..authsession import (
|
from ..authsession import (
|
||||||
delete_credential,
|
delete_credential,
|
||||||
@@ -17,12 +18,21 @@ from ..authsession import (
|
|||||||
from ..globals import db
|
from ..globals import db
|
||||||
from ..util import hostutil, passphrase, tokens
|
from ..util import hostutil, passphrase, tokens
|
||||||
from ..util.tokens import decode_session_key, session_key
|
from ..util.tokens import decode_session_key, session_key
|
||||||
from . import session
|
from . import authz, session
|
||||||
from .session import AUTH_COOKIE
|
from .session import AUTH_COOKIE
|
||||||
|
|
||||||
app = FastAPI()
|
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")
|
@app.put("/display-name")
|
||||||
async def user_update_display_name(
|
async def user_update_display_name(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -31,11 +41,15 @@ async def user_update_display_name(
|
|||||||
auth=AUTH_COOKIE,
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
if not auth:
|
if not auth:
|
||||||
raise HTTPException(status_code=401, detail="Authentication Required")
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Authentication Required", mode="login"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
s = await get_session(auth, host=request.headers.get("host"))
|
s = await get_session(auth, host=request.headers.get("host"))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=401, detail="Session expired") from e
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Session expired", mode="login"
|
||||||
|
) from e
|
||||||
new_name = (payload.get("display_name") or "").strip()
|
new_name = (payload.get("display_name") or "").strip()
|
||||||
if not new_name:
|
if not new_name:
|
||||||
raise HTTPException(status_code=400, detail="display_name required")
|
raise HTTPException(status_code=400, detail="display_name required")
|
||||||
@@ -52,7 +66,9 @@ async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE)
|
|||||||
try:
|
try:
|
||||||
s = await get_session(auth, host=request.headers.get("host"))
|
s = await get_session(auth, host=request.headers.get("host"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=401, detail="Session expired")
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Session expired", mode="login"
|
||||||
|
)
|
||||||
await db.instance.delete_sessions_for_user(s.user_uuid)
|
await db.instance.delete_sessions_for_user(s.user_uuid)
|
||||||
session.clear_session_cookie(response)
|
session.clear_session_cookie(response)
|
||||||
return {"message": "Logged out from all hosts"}
|
return {"message": "Logged out from all hosts"}
|
||||||
@@ -66,11 +82,15 @@ async def api_delete_session(
|
|||||||
auth=AUTH_COOKIE,
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
if not auth:
|
if not auth:
|
||||||
raise HTTPException(status_code=401, detail="Authentication Required")
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Authentication Required", mode="login"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
current_session = await get_session(auth, host=request.headers.get("host"))
|
current_session = await get_session(auth, host=request.headers.get("host"))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=401, detail="Session expired") from exc
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Session expired", mode="login"
|
||||||
|
) from exc
|
||||||
|
|
||||||
try:
|
try:
|
||||||
target_key = decode_session_key(session_id)
|
target_key = decode_session_key(session_id)
|
||||||
@@ -97,10 +117,14 @@ async def api_delete_credential(
|
|||||||
uuid: UUID,
|
uuid: UUID,
|
||||||
auth: str = AUTH_COOKIE,
|
auth: str = AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
|
# Require recent authentication for sensitive operation
|
||||||
|
await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
|
||||||
try:
|
try:
|
||||||
await delete_credential(uuid, auth, host=request.headers.get("host"))
|
await delete_credential(uuid, auth, host=request.headers.get("host"))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=401, detail="Session expired") from e
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Session expired", mode="login"
|
||||||
|
) from e
|
||||||
return {"message": "Credential deleted successfully"}
|
return {"message": "Credential deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
@@ -110,10 +134,14 @@ async def api_create_link(
|
|||||||
response: Response,
|
response: Response,
|
||||||
auth=AUTH_COOKIE,
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
|
# Require recent authentication for sensitive operation
|
||||||
|
await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
|
||||||
try:
|
try:
|
||||||
s = await get_session(auth, host=request.headers.get("host"))
|
s = await get_session(auth, host=request.headers.get("host"))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=401, detail="Session expired") from e
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Session expired", mode="login"
|
||||||
|
) from e
|
||||||
token = passphrase.generate()
|
token = passphrase.generate()
|
||||||
expiry = expires()
|
expiry = expires()
|
||||||
await db.instance.create_reset_token(
|
await db.instance.create_reset_token(
|
||||||
|
|||||||
+50
-13
@@ -9,6 +9,7 @@ from ..authsession import create_session, get_reset, get_session
|
|||||||
from ..globals import db, passkey
|
from ..globals import db, passkey
|
||||||
from ..util import passphrase
|
from ..util import passphrase
|
||||||
from ..util.tokens import create_token, session_key
|
from ..util.tokens import create_token, session_key
|
||||||
|
from . import authz
|
||||||
from .session import AUTH_COOKIE, infodict
|
from .session import AUTH_COOKIE, infodict
|
||||||
|
|
||||||
|
|
||||||
@@ -21,11 +22,18 @@ def websocket_error_handler(func):
|
|||||||
return await func(ws, *args, **kwargs)
|
return await func(ws, *args, **kwargs)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
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:
|
except (ValueError, InvalidAuthenticationResponse) as e:
|
||||||
await ws.send_json({"detail": str(e)})
|
await ws.send_json({"status": 401, "detail": str(e)})
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Internal Server Error")
|
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
|
return wrapper
|
||||||
|
|
||||||
@@ -48,7 +56,7 @@ async def register_chat(
|
|||||||
credential_ids=credential_ids,
|
credential_ids=credential_ids,
|
||||||
origin=origin,
|
origin=origin,
|
||||||
)
|
)
|
||||||
await ws.send_json(options)
|
await ws.send_json({"optionsJSON": options})
|
||||||
response = await ws.receive_json()
|
response = await ws.receive_json()
|
||||||
return passkey.instance.reg_verify(response, challenge, user_uuid, origin=origin)
|
return passkey.instance.reg_verify(response, challenge, user_uuid, origin=origin)
|
||||||
|
|
||||||
@@ -64,20 +72,23 @@ async def websocket_register_add(
|
|||||||
"""Register a new credential for an existing user.
|
"""Register a new credential for an existing user.
|
||||||
|
|
||||||
Supports either:
|
Supports either:
|
||||||
- Normal session via auth cookie
|
- Normal session via auth cookie (requires recent authentication)
|
||||||
- Reset token supplied as ?reset=... (auth cookie ignored)
|
- Reset token supplied as ?reset=... (auth cookie ignored)
|
||||||
"""
|
"""
|
||||||
origin = ws.headers["origin"]
|
origin = ws.headers["origin"]
|
||||||
host = origin.split("://", 1)[1]
|
host = origin.split("://", 1)[1]
|
||||||
if reset is not None:
|
if reset is not None:
|
||||||
if not passphrase.is_well_formed(reset):
|
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)
|
s = await get_reset(reset)
|
||||||
|
user_uuid = s.user_uuid
|
||||||
else:
|
else:
|
||||||
if not auth:
|
# Require recent authentication for adding a new passkey
|
||||||
raise ValueError("Authentication Required")
|
ctx = await authz.verify(auth, perm=[], host=host, max_age="5m")
|
||||||
s = await get_session(auth, host=host)
|
user_uuid = ctx.session.user_uuid
|
||||||
user_uuid = s.user_uuid
|
s = ctx.session
|
||||||
|
|
||||||
# Get user information and determine effective user_name for this registration
|
# Get user information and determine effective user_name for this registration
|
||||||
user = await db.instance.get_user_by_uuid(user_uuid)
|
user = await db.instance.get_user_by_uuid(user_uuid)
|
||||||
@@ -119,15 +130,41 @@ async def websocket_register_add(
|
|||||||
|
|
||||||
@app.websocket("/authenticate")
|
@app.websocket("/authenticate")
|
||||||
@websocket_error_handler
|
@websocket_error_handler
|
||||||
async def websocket_authenticate(ws: WebSocket):
|
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||||
origin = ws.headers["origin"]
|
origin = ws.headers["origin"]
|
||||||
host = origin.split("://", 1)[1]
|
host = origin.split("://", 1)[1]
|
||||||
options, challenge = passkey.instance.auth_generate_options()
|
|
||||||
await ws.send_json(options)
|
# 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
|
# Wait for the client to use his authenticator to authenticate
|
||||||
credential = passkey.instance.auth_parse(await ws.receive_json())
|
credential = passkey.instance.auth_parse(await ws.receive_json())
|
||||||
# Fetch from the database by credential ID
|
# 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
|
# Verify the credential matches the stored data
|
||||||
passkey.instance.auth_verify(credential, challenge, stored_cred, origin=origin)
|
passkey.instance.auth_verify(credential, challenge, stored_cred, origin=origin)
|
||||||
# Update both credential and user's last_seen timestamp
|
# Update both credential and user's last_seen timestamp
|
||||||
|
|||||||
+1
-1
@@ -184,7 +184,7 @@ class Passkey:
|
|||||||
authopts: Additional arguments to generate_authentication_options.
|
authopts: Additional arguments to generate_authentication_options.
|
||||||
|
|
||||||
Returns:
|
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(
|
options = generate_authentication_options(
|
||||||
rp_id=self.rp_id,
|
rp_id=self.rp_id,
|
||||||
|
|||||||
+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 importlib import resources
|
||||||
from pathlib import Path
|
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:
|
def _resolve_static_dir() -> Path:
|
||||||
@@ -25,13 +60,47 @@ def file(*parts: str) -> Path:
|
|||||||
return path.joinpath(*parts)
|
return path.joinpath(*parts)
|
||||||
|
|
||||||
|
|
||||||
def run_dev():
|
def is_dev_mode() -> bool:
|
||||||
"""Spawn the frontend dev server (bun or npm) as a background process."""
|
"""Check if we're running in dev mode (Vite frontend server)."""
|
||||||
import atexit
|
return os.environ.get("PASSKEY_DEVMODE") == "1"
|
||||||
import shutil
|
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
devpath = Path(__file__).parent.parent.parent / "frontend"
|
||||||
if not (devpath / "package.json").exists():
|
if not (devpath / "package.json").exists():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -39,22 +108,47 @@ def run_dev():
|
|||||||
if "site-packages" in devpath.parts
|
if "site-packages" in devpath.parts
|
||||||
else f"Frontend source code not found at {devpath}"
|
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():
|
options = [
|
||||||
if proc.poll() is None:
|
("deno", "run", "dev"),
|
||||||
proc.terminate()
|
("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):
|
def start_vite():
|
||||||
_terminate()
|
nonlocal vite_process
|
||||||
raise SystemExit(0)
|
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):
|
stderr.write(f">>> {' '.join([tool_name, *cmd[1:]])}\n")
|
||||||
signal.signal(sig, _signal_handler)
|
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,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")
|
||||||
@@ -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}")
|
||||||
@@ -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},
|
||||||
|
}
|
||||||
+35
-34
@@ -1,47 +1,48 @@
|
|||||||
import os
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from contextlib import contextmanager
|
from pathlib import Path
|
||||||
from sys import stderr
|
from sys import stderr
|
||||||
|
|
||||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
def run(cmd, **kwargs):
|
||||||
def chdir(path):
|
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
|
||||||
original = os.getcwd()
|
stderr.write(f"### {' '.join(display_cmd)}\n")
|
||||||
os.chdir(path)
|
subprocess.run(cmd, check=True, **kwargs)
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
def find_build_tool():
|
||||||
os.chdir(original)
|
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):
|
class CustomBuildHook(BuildHookInterface):
|
||||||
def initialize(self, version, build_data):
|
def initialize(self, version, build_data):
|
||||||
super().initialize(version, build_data)
|
super().initialize(version, build_data)
|
||||||
stderr.write(">>> Building the frontend\n")
|
stderr.write(">>> Building the frontend\n")
|
||||||
npm = None
|
|
||||||
bun = shutil.which("bun")
|
install_cmd, build_cmd = find_build_tool()
|
||||||
if bun is None:
|
|
||||||
npm = shutil.which("npm")
|
try:
|
||||||
if npm is None:
|
run(install_cmd, cwd="frontend")
|
||||||
raise RuntimeError(
|
stderr.write("\n")
|
||||||
"Bun or NodeJS `npm` is required for building but neither was found"
|
run(build_cmd, cwd="frontend")
|
||||||
)
|
except Exception as e:
|
||||||
# npm --prefix doesn't work on Windows, so we chdir instead
|
stderr.write(f"Error occurred while building frontend: {e}\n")
|
||||||
with chdir("frontend"):
|
raise
|
||||||
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")
|
|
||||||
|
|||||||
Reference in New Issue
Block a user