32 Commits

Author SHA1 Message Date
Leo Vasanko
07525b47ae Centralise all cookie handling to session.py. 2025-10-04 18:48:24 -06:00
Leo Vasanko
1ad1644b64 Refactor /api/user/* to its own module. 2025-10-04 18:41:35 -06:00
Leo Vasanko
876215f1c1 Reset dialog UX improved. 2025-10-04 18:40:46 -06:00
Leo Vasanko
59e7e40128 Harmonise ProfileView and HostApp. 2025-10-04 18:14:17 -06:00
Leo Vasanko
a0da799c9e Tuning the host app. 2025-10-04 18:06:47 -06:00
Leo Vasanko
94efb00e34 Don't redirect non-auth-host /auth/ to auth site but show basic info on current host, and allow logging out. Adds a new host app for this purpose. 2025-10-04 17:55:08 -06:00
Leo Vasanko
f9f4d59c6b Deny creating sessions for hosts other than rp-id subdomains. 2025-10-04 17:26:03 -06:00
Leo Vasanko
45f9870d0d WebSockets must use origin for finding the host calling them. 2025-10-04 17:16:51 -06:00
Leo Vasanko
2a81544701 Correction on restricted path checking (auth-host). 2025-10-04 16:59:05 -06:00
Leo Vasanko
a60c1bd5f5 Refactor auth-host redirection middleware to its own module.
Implement redirection to remove /auth/ from UI URLs when on auth-host.
2025-10-04 16:49:23 -06:00
Leo Vasanko
229f066533 Add validation of the CLI specified --auth-host (needs to be within rp-id). 2025-10-04 16:35:55 -06:00
Leo Vasanko
97f653e116 Fix deletion of session cookie on host logout. 2025-10-04 16:26:36 -06:00
Leo Vasanko
29be642dbe Better UX for profile view logout buttons. 2025-10-04 16:22:16 -06:00
Leo Vasanko
bfb11cc20f A major refactoring for more consistent and stricter flows.
- Force using the dedicated authentication site configured via auth-host
- Stricter host validation
- Using the restricted app consistently for all access control (instead of the old loginview).
2025-10-04 15:55:43 -06:00
Leo Vasanko
389e05730b Refactor user editing endpoints (only auth site) under api/user/ while leaving host-based endpoints at api root. 2025-10-04 08:59:51 -06:00
Leo Vasanko
79b6c50a9c More consistent shared styling between credential and session cards. 2025-10-04 08:32:27 -06:00
Leo Vasanko
591ea626bf Add host-based authentication, UTC timestamps, session management, and secure cookies; fix styling issues; refactor to remove module; update database schema for sessions and reset tokens. 2025-10-03 18:31:54 -06:00
Leo Vasanko
963ab06664 Use git tag versioning for the Python project. 2025-10-02 16:09:26 -06:00
Leo Vasanko
bb35e57ba4 Fix reset link logic to include /auth when no configured auth-host. 2025-10-02 15:57:20 -06:00
Leo Vasanko
5d8304bbd9 Refactor user-profile, restricted access and reset token registration as separate apps so the frontend does not need to guess which context it is running in.
Support user-navigable URLs at / as well as /auth/, allowing for a dedicated authentication site with pretty URLs.
2025-10-02 15:44:48 -06:00
Leo Vasanko
fbfd0bbb47 Create registration links on the same host (subdomain) that is being used by the one who creates it. 2025-10-02 12:30:50 -06:00
Leo Vasanko
eb38995cca Version 0.2.0 2025-09-30 17:04:53 -06:00
Leo Vasanko
382341e5ee Make the login/reset/forbidden dialogs look better. 2025-09-30 17:03:51 -06:00
Leo Vasanko
ed7d3ee0fc Admin app: guard rails extended, consistent styling, also share styling with main app. 2025-09-30 16:38:14 -06:00
Leo Vasanko
3dff459068 Remove duplicate message from permission denied page. 2025-09-30 12:56:41 -06:00
Leo Vasanko
89b40cd080 Admin app divided to separate components. 2025-09-30 12:54:18 -06:00
Leo Vasanko
d46d50b91a Massive style redesign, WIP. 2025-09-29 21:02:49 -06:00
Leo Vasanko
39beb31347 Simplified Caddy snippets (removed auth/all). 2025-09-28 20:00:19 -06:00
Leo Vasanko
41e6eb9a5a Version 0.1.2 2025-09-28 19:46:49 -06:00
Leo Vasanko
d5bc3e773d Clear sessionStorage on logout. 2025-09-28 19:45:37 -06:00
Leo Vasanko
ac0256c366 chore: bump version to 0.1.1 2025-09-27 20:51:46 -06:00
Leo Vasanko
6439437e8b Implement breadcrumb navigation. 2025-09-27 20:47:45 -06:00
60 changed files with 4454 additions and 1741 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ dist/
passkey-auth.sqlite
/passkey/frontend-build
/test_*.py
passkey/_version.py

120
API.md
View File

@@ -1,28 +1,104 @@
# PassKey Auth API Documentation
This document describes all API endpoints available in the PassKey Auth FastAPI application, that by default listens on `localhost:4401` ("for authentication required").
This document lists the HTTP and WebSocket endpoints exposed by the PassKey Auth
service and how they behave depending on whether a dedicated authentication host
(`--auth-host` / environment `PASSKEY_AUTH_HOST`) is configured.
### HTTP Endpoints
## Base Paths & Host Modes
GET /auth/ - Main authentication app
GET /auth/admin/ - Admin app for managing organisations, users and permissions
GET /auth/{reset_token} - Process password reset/share token
POST /auth/api/user-info - Get authenticated user information
POST /auth/api/logout - Logout and delete session
POST /auth/api/set-session - Set session cookie from Authorization header
POST /auth/api/create-link - Create device addition link
DELETE /auth/api/credential/{uuid} - Delete specific credential
POST /auth/api/validate - Session validation and renewal endpoint (fetch regularly)
GET /auth/api/forward - Authentication validation for Caddy/Nginx
- On success returns `204 No Content` with [user info](Headers.md)
- Otherwise returns
* `401 Unauthorized` - authentication required
* `403 Forbidden` - missing required permissions
* Serves the authentication app for a login or permission denied page
- Does not renew session!
Two deployment modes:
### WebAuthn/Passkey endpoints (WebSockets)
1. Multihost (default no `--auth-host` provided)
- All endpoints are reachable on any host under the `/auth/` prefix.
- A convenience root (`/`) also serves the main app.
WS /auth/ws/register - Register new user with passkey
WS /auth/ws/add_credential - Add new credential for existing user
WS /auth/ws/authenticate - Authenticate user with passkey
2. Dedicated auth host (`--auth-host auth.example.com`)
- The specified auth host serves the UI at the root (`/`, `/admin/`, reset tokens, etc.).
- Other (nonauth) hosts show a lightweight account summary at `/` or `/auth/`, while other UI routes still redirect to the auth host.
- Restricted endpoints on nonauth hosts return `404` instead of redirecting.
### Path Mapping When Auth Host Enabled
| Purpose | On Auth Host | On Other Hosts (incoming) | Action |
|---------|--------------|---------------------------|--------|
| Main UI | `/` | `/auth/` or `/` | Serve account summary SPA (no redirect) |
| Admin UI root | `/admin/` | `/auth/admin/` or `/admin/` | Redirect -> auth host `/admin/` (strip `/auth`) |
| Reset / device addition token | `/{token}` | `/auth/{token}` | Redirect -> auth host `/{token}` (strip `/auth`) |
| Static assets | `/auth/assets/*` | `/auth/assets/*` | Served directly (no redirect) |
| Unrestricted API | `/auth/api/...` | `/auth/api/...` | Served directly |
| Restricted API (admin,user,ws namespaces) | `/auth/api/{admin|user|ws}*` | same path | 404 on nonauth hosts |
| WebSocket (register/auth) | `/auth/ws/*` | `/auth/ws/*` | 404 on nonauth hosts |
Notes:
- “Strip `/auth`” means only when the path starts with that exact segment.
- A reset token is a single path segment validated by server logic; malformed tokens 404.
- Method and body are preserved for UI redirects (307 Temporary Redirect).
## HTTP UI Endpoints
| Method | Path (multihost) | Path (auth host) | Description |
|--------|-------------------|------------------|-------------|
| GET | `/auth/` | `/` | Main authentication SPA (non-auth hosts show an account summary view) |
| GET | `/auth/admin/` | `/admin/` | Admin SPA root |
| GET | `/auth/{reset_token}` | `/{reset_token}` | Reset / device addition SPA (token validated) |
| GET | `/auth/restricted` | `/restricted` | Restricted / permission denied SPA |
## Core API (Unrestricted available on all hosts)
Always under `/auth/api/` (even on auth host):
| Method | Path | Description |
|--------|------|-------------|
| POST | `/auth/api/validate` | Validate & (conditionally) renew session |
| GET | `/auth/api/forward` | Auth proxy endpoint for reverse proxies (204 or 4xx) |
| POST | `/auth/api/set-session` | Set cookie from Bearer token |
| POST | `/auth/api/logout` | Logout current session |
| POST | `/auth/api/user-info` | Authenticated user + context info (also handles reset tokens) |
| POST | `/auth/api/create-link` | Create a device addition link (reset token) |
| DELETE | `/auth/api/credential/{uuid}` | Delete user credential |
| DELETE | `/auth/api/session/{session_id}` | Terminate a specific session |
| POST | `/auth/api/user/logout-all` | Terminate all sessions for the user |
| PUT | `/auth/api/user/display-name` | Update display name |
## Restricted API Namespaces
When `--auth-host` is set, requests to these paths on nonauth hosts return 404:
| Namespace | Examples |
|-----------|----------|
| `/auth/api/admin` | `/auth/api/admin/orgs`, `/auth/api/admin/orgs/{uuid}` ... |
| `/auth/api/user` | Segment prefix includes `/auth/api/user/...` endpoints (logout-all, display-name, session, credential) |
| `/auth/api/ws` | (Reserved / future) |
## WebSockets (Passkey)
| Path | Description | Host Mode Behavior |
|------|-------------|--------------------|
| `/auth/ws/register` | Register new credential (new or existing user) | 404 on nonauth hosts when auth host configured |
| `/auth/ws/authenticate` | Authenticate user & issue session | 404 on nonauth hosts when auth host configured |
## Redirection & Status Codes
| Scenario | Response |
|----------|----------|
| UI path on nonauth host (auth host configured) | 307 redirect to auth host; `/auth` prefix stripped |
| Reset token UI path on nonauth host | 307 redirect (token preserved) |
| Restricted API on nonauth host | 404 |
| Unrestricted API on any host | Normal response |
| No auth host configured | All hosts behave like multi-host mode (no redirects; everything accessible) |
## Headers for /auth/api/forward
See `Headers.md` for details of headers returned on success (204).
## Notes for Integrators
1. Always use absolute `/auth/api/...` paths for programmatic requests (they do not move when an auth host is introduced).
2. Bookmark / deep links to UI should resolve correctly after redirection if users access via a non-auth application host.
3. Treat 404 from restricted namespaces on non-auth hosts as a signal to direct users to the central auth site.
## Environment & CLI Summary
| Option | Effect |
|--------|--------|
| `--auth-host` / `PASSKEY_AUTH_HOST` | Enables dedicated host mode, root-mounts UI there, restricts certain namespaces elsewhere |
---
This document reflects current behavior of the middleware-based host routing logic.

View File

@@ -1,4 +1,5 @@
localhost {
# Setup the authentication site at /auth/
import auth/setup
# Only users with myapp:reports and auth admin permissions
handle_path /reports {
@@ -22,16 +23,3 @@ localhost {
reverse_proxy :3000
}
}
example.com {
# Public endpoints in handle blocks before auth
@public path /favicon.ico /.well-known/*
handle @public {
root * /var/www/
file_server
}
# The rest of the site protected, /auth/ reserved for auth service
import auth/all perm=auth:admin {
reverse_proxy :3000
}
}

View File

@@ -1,6 +0,0 @@
# Enable auth site at /auth (setup) and require authentication on all paths
import setup
handle {
import require {args[0]}
{block}
}

View File

@@ -1,5 +1,7 @@
# Permission to use within your endpoints that need authentication/authorization, that
# is different depending on the route (otherwise use auth/all).
# Permission to use within your endpoints that need authentication/authorization
# Argument is mandatory and provides a query string to /auth/api/forward
# "" means just authentication
# perm=yourservice:login to require specific permission
forward_auth {$AUTH_UPSTREAM:localhost:4401} {
uri /auth/api/forward?{args[0]}
header_up Connection keep-alive # Much higher performance

12
frontend/host/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Account Summary</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/host/main.js"></script>
</body>
</html>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authentication</title>
<title>Auth Profile</title>
</head>
<body>
<div id="app"></div>

12
frontend/reset/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Complete Passkey Setup</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/reset/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Access Restricted</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/restricted/main.js"></script>
</body>
</html>

View File

@@ -1,59 +1,35 @@
<template>
<div>
<div class="app-shell">
<StatusMessage />
<LoginView v-if="store.currentView === 'login'" />
<ProfileView v-if="store.currentView === 'profile'" />
<DeviceLinkView v-if="store.currentView === 'device-link'" />
<ResetView v-if="store.currentView === 'reset'" />
<PermissionDeniedView v-if="store.currentView === 'permission-denied'" />
<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 } from 'vue'
import { onMounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import StatusMessage from '@/components/StatusMessage.vue'
import LoginView from '@/components/LoginView.vue'
import ProfileView from '@/components/ProfileView.vue'
import DeviceLinkView from '@/components/DeviceLinkView.vue'
import ResetView from '@/components/ResetView.vue'
import PermissionDeniedView from '@/components/PermissionDeniedView.vue'
const store = useAuthStore()
const initialized = ref(false)
onMounted(async () => {
// Detect restricted mode:
// We only allow full functionality on the exact /auth/ (or /auth) path.
// Any other path (including /, /foo, /auth/admin, etc.) is treated as restricted
// so the app will only show login or permission denied views.
const path = location.pathname
if (!(path === '/auth/' || path === '/auth')) {
store.setRestrictedMode(true)
}
// Load branding / settings first (non-blocking for auth flow)
await store.loadSettings()
// Was an error message passed in the URL hash?
const message = location.hash.substring(1)
if (message) {
store.showMessage(decodeURIComponent(message), 'error')
history.replaceState(null, '', location.pathname)
}
// Capture reset token from query parameter and then remove it
const params = new URLSearchParams(location.search)
const reset = params.get('reset')
if (reset) {
store.resetToken = reset
// Remove query param to avoid lingering in history / clipboard
const targetPath = '/auth/'
const currentPath = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/'
history.replaceState(null, '', currentPath.startsWith('/auth') ? '/auth/' : targetPath)
}
try {
await store.loadUserInfo()
} catch (error) {
console.log('Failed to load user info:', error)
store.currentView = 'login'
}
store.selectView()
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>

View File

@@ -1,10 +1,16 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import Breadcrumbs from '@/components/Breadcrumbs.vue'
import CredentialList from '@/components/CredentialList.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import StatusMessage from '@/components/StatusMessage.vue'
import AdminOverview from './AdminOverview.vue'
import AdminOrgDetail from './AdminOrgDetail.vue'
import AdminUserDetail from './AdminUserDetail.vue'
import AdminDialogs from './AdminDialogs.vue'
import { useAuthStore } from '@/stores/auth'
import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings'
const info = ref(null)
const loading = ref(true)
@@ -19,15 +25,13 @@ const userLinkExpires = ref(null)
const authStore = useAuthStore()
const addingOrgForPermission = ref(null)
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
const showCreatePermission = ref(false)
const newPermId = ref('')
const newPermName = ref('')
const editingPermId = ref(null)
const renameIdValue = ref('')
const editingPermDisplay = ref(null)
const renameDisplayValue = ref('')
const dialog = ref({ type: null, data: null, busy: false, error: '' })
const safeIdRegex = /[^A-Za-z0-9:._~-]/g
function sanitizeNewId() { if (newPermId.value) newPermId.value = newPermId.value.replace(safeIdRegex, '') }
function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') }
function handleGlobalClick(e) {
@@ -51,7 +55,9 @@ const permissionSummary = computed(() => {
const summary = {}
for (const o of orgs.value) {
const orgBase = { uuid: o.uuid, display_name: o.display_name }
// Org-level permissions (direct)
const orgPerms = new Set(o.permissions || [])
// Org-level permissions (direct) - only count if org can grant them
for (const pid of o.permissions || []) {
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
if (!summary[pid].orgSet.has(o.uuid)) {
@@ -59,9 +65,13 @@ const permissionSummary = computed(() => {
summary[pid].orgSet.add(o.uuid)
}
}
// Role-based permissions (inheritance)
// Role-based permissions (inheritance) - only count if org can grant them
for (const r of o.roles) {
for (const pid of r.permissions) {
// Only count if the org can grant this permission
if (!orgPerms.has(pid)) continue
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
if (!summary[pid].orgSet.has(o.uuid)) {
summary[pid].orgs.push(orgBase)
@@ -78,25 +88,7 @@ const permissionSummary = computed(() => {
return display
})
function availableOrgsForPermission(pid) {
return orgs.value.filter(o => !o.permissions.includes(pid))
}
function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p }) }
function startRenamePermissionId(p) { editingPermId.value = p.id; renameIdValue.value = p.id }
function cancelRenameId() { editingPermId.value = null; renameIdValue.value = '' }
async function submitRenamePermissionId(p) {
const newId = renameIdValue.value.trim()
if (!newId || newId === p.id) { cancelRenameId(); return }
try {
const body = { old_id: p.id, new_id: newId, display_name: p.display_name }
const res = await fetch('/auth/admin/permission/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
let data; try { data = await res.json() } catch(_) { data = {} }
if (!res.ok || data.detail) throw new Error(data.detail || data.error || `Failed (${res.status})`)
await refreshPermissionsContext(); cancelRenameId()
} catch (e) { authStore.showMessage(e?.message || 'Rename failed') }
}
function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p, id: p.id, display_name: p.display_name }) }
async function refreshPermissionsContext() {
// Reload both lists so All Permissions table shows new associations promptly.
@@ -179,6 +171,7 @@ async function load() {
if (!window.location.hash || window.location.hash === '#overview') {
currentOrgId.value = orgs.value[0].uuid
window.location.hash = `#org/${currentOrgId.value}`
authStore.showMessage(`Navigating to ${orgs.value[0].display_name} Administration`, 'info', 3000)
} else {
parseHash()
}
@@ -193,14 +186,16 @@ async function load() {
// Org actions
function createOrg() { openDialog('org-create', {}) }
function updateOrg(org) { openDialog('org-update', { org }) }
function updateOrg(org) { openDialog('org-update', { org, name: org.display_name }) }
function editUserName(user) { openDialog('user-update-name', { user, name: user.display_name }) }
function deleteOrg(org) {
if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return }
openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => {
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' })
const data = await res.json(); if (data.detail) throw new Error(data.detail)
await loadOrgs()
await Promise.all([loadOrgs(), loadPermissions()])
} })
}
@@ -246,7 +241,7 @@ async function removeOrgPermission() { /* obsolete */ }
// Role actions
function createRole(org) { openDialog('role-create', { org }) }
function updateRole(role) { openDialog('role-update', { role }) }
function updateRole(role) { openDialog('role-update', { role, name: role.display_name }) }
function deleteRole(role) {
openDialog('confirm', { message: `Delete role ${role.display_name}?`, action: async () => {
@@ -256,17 +251,32 @@ function deleteRole(role) {
} })
}
// Permission actions
async function submitCreatePermission() {
const id = newPermId.value.trim()
const name = newPermName.value.trim()
if (!id || !name) return
const res = await fetch('/auth/admin/permissions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, display_name: name }) })
const data = await res.json(); if (data.detail) { authStore.showMessage(data.detail); return }
await loadPermissions(); newPermId.value=''; newPermName.value=''; showCreatePermission.value=false
async function toggleRolePermission(role, pid, checked) {
// Calculate new permissions array
const newPermissions = checked
? [...role.permissions, pid]
: role.permissions.filter(p => p !== pid)
// Optimistic update
const prevPermissions = [...role.permissions]
role.permissions = newPermissions
try {
const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ display_name: role.display_name, permissions: newPermissions })
})
const data = await res.json()
if (data.detail) throw new Error(data.detail)
await loadOrgs()
} catch (e) {
authStore.showMessage(e.message || 'Failed to update role permission')
role.permissions = prevPermissions // revert
}
}
function cancelCreatePermission() { newPermId.value=''; newPermName.value=''; showCreatePermission.value=false }
// Permission actions
function updatePermission(p) { openDialog('perm-display', { permission: p }) }
function deletePermission(p) {
@@ -280,10 +290,8 @@ function deletePermission(p) {
onMounted(async () => {
window.addEventListener('hashchange', parseHash)
await authStore.loadSettings()
if (authStore.settings?.rp_name) {
document.title = authStore.settings.rp_name + ' Admin'
}
const settings = await getSettings()
if (settings?.rp_name) document.title = settings.rp_name + ' Admin'
load()
})
@@ -313,9 +321,30 @@ const selectedUser = computed(() => {
})
const pageHeading = computed(() => {
if (selectedUser.value) return 'Organization Admin'
if (selectedOrg.value) return 'Organization Admin'
return (authStore.settings?.rp_name || 'Passkey') + ' Admin'
if (selectedUser.value) return 'Admin: User'
if (selectedOrg.value) return 'Admin: Org'
return ((authStore.settings?.rp_name) || 'Master') + ' Admin'
})
// Breadcrumb entries for admin app.
const breadcrumbEntries = computed(() => {
const entries = [
{ label: 'Auth', href: makeUiHref() },
{ label: 'Admin', href: adminUiPath() }
]
// Determine organization for user view if selectedOrg not explicitly chosen.
let orgForUser = null
if (selectedUser.value) {
orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org_uuid) || null
}
const orgToShow = selectedOrg.value || orgForUser
if (orgToShow) {
entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` })
}
if (selectedUser.value) {
entries.push({ label: selectedUser.value.display_name || 'User', href: `#user/${selectedUser.value.uuid}` })
}
return entries
})
watch(selectedUser, async (u) => {
@@ -349,26 +378,24 @@ function permissionDisplayName(id) {
return permissions.value.find(p => p.id === id)?.display_name || id
}
async function toggleRolePermission(role, permId, checked) {
async function toggleOrgPermission(org, permId, checked) {
// Build next permission list
const has = role.permissions.includes(permId)
const has = org.permissions.includes(permId)
if (checked && has) return
if (!checked && !has) return
const next = checked ? [...role.permissions, permId] : role.permissions.filter(p => p !== permId)
const next = checked ? [...org.permissions, permId] : org.permissions.filter(p => p !== permId)
// Optimistic update
const prev = [...role.permissions]
role.permissions = next
const prev = [...org.permissions]
org.permissions = next
try {
const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ display_name: role.display_name, permissions: next })
})
const params = new URLSearchParams({ permission_id: permId })
const res = await fetch(`/auth/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()
} catch (e) {
authStore.showMessage(e.message || 'Failed to update role permission')
role.permissions = prev // revert
authStore.showMessage(e.message || 'Failed to update organization permission')
org.permissions = prev // revert
}
}
@@ -407,19 +434,42 @@ async function submitDialog() {
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
} else if (t === 'role-update') {
const { role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
const permsCsv = dialog.value.data.perms || ''
const perms = permsCsv.split(',').map(s=>s.trim()).filter(Boolean)
const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: perms }) })
const 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 }) })
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
} else if (t === 'user-create') {
const { org, role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
const res = await fetch(`/auth/admin/orgs/${org.uuid}/users`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, role: role.display_name }) })
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
} else if (t === 'user-update-name') {
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 }) })
const d = await res.json(); if (d.detail) throw new Error(d.detail); await onUserNameSaved()
} else if (t === 'perm-display') {
const { permission } = dialog.value.data; const display = dialog.value.data.display_name?.trim(); if (!display) throw new Error('Display name required')
const params = new URLSearchParams({ permission_id: permission.id, display_name: display })
const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'PUT' })
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadPermissions()
const { permission } = dialog.value.data
const newId = dialog.value.data.id?.trim()
const newDisplay = dialog.value.data.display_name?.trim()
if (!newDisplay) throw new Error('Display name required')
if (!newId) throw new Error('ID required')
if (newId !== permission.id) {
// ID changed, use rename endpoint
const 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) {
// Only display name changed
const params = new URLSearchParams({ permission_id: permission.id, display_name: newDisplay })
const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'PUT' })
const d = await res.json(); if (d.detail) throw new Error(d.detail)
}
await loadPermissions()
} else if (t === 'perm-create') {
const id = dialog.value.data.id?.trim(); if (!id) throw new Error('ID required')
const display_name = dialog.value.data.display_name?.trim(); if (!display_name) throw new Error('Display name required')
const res = await fetch('/auth/admin/permissions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ 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 = ''
} else if (t === 'confirm') {
const action = dialog.value.data.action; if (action) await action()
}
@@ -431,447 +481,94 @@ async function submitDialog() {
</script>
<template>
<div class="container">
<h1>
{{ pageHeading }}
<a href="/auth/" class="back-link" title="Back to User App">User</a>
<a
v-if="info?.is_global_admin && (selectedOrg || selectedUser)"
@click.prevent="goOverview"
href="#overview"
class="nav-link"
title="Back to overview"
>Overview</a>
</h1>
<div v-if="loading">Loading</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else>
<div v-if="!info?.authenticated">
<p>You must be authenticated.</p>
</div>
<div v-else-if="!(info?.is_global_admin || info?.is_org_admin)">
<p>Insufficient permissions.</p>
</div>
<div v-else>
<div class="app-shell admin-shell">
<StatusMessage />
<main class="app-main">
<section class="view-root view-admin">
<div class="view-content view-content--wide">
<header class="view-header">
<h1>{{ pageHeading }}</h1>
<Breadcrumbs :entries="breadcrumbEntries" />
</header>
<!-- Removed user-specific info (current org, effective permissions, admin flags) -->
<!-- Overview Page -->
<div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card">
<h2>Organizations</h2>
<div class="actions">
<button @click="createOrg" v-if="info.is_global_admin">+ Create Org</button>
</div>
<table class="org-table">
<thead>
<tr>
<th>Name</th>
<th>Roles</th>
<th>Members</th>
<th v-if="info.is_global_admin">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="o in orgs" :key="o.uuid">
<td><a href="#org/{{o.uuid}}" @click.prevent="openOrg(o)">{{ o.display_name }}</a></td>
<td>{{ o.roles.length }}</td>
<td>{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td>
<td v-if="info.is_global_admin">
<button @click="updateOrg(o)" class="icon-btn" aria-label="Rename organization" title="Rename organization"></button>
<button @click="deleteOrg(o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization"></button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- User Detail Page -->
<div v-if="selectedUser" class="card user-detail">
<UserBasicInfo
v-if="userDetail && !userDetail.error"
:name="userDetail.display_name || selectedUser.display_name"
:visits="userDetail.visits"
:created-at="userDetail.created_at"
:last-seen="userDetail.last_seen"
:loading="loading"
:org-display-name="userDetail.org.display_name"
:role-name="userDetail.role"
:update-endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
@saved="onUserNameSaved"
/>
<div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
<template v-if="userDetail && !userDetail.error">
<h3 class="cred-title">Registered Passkeys</h3>
<CredentialList :credentials="userDetail.credentials" :aaguid-info="userDetail.aaguid_info" />
</template>
<div class="actions">
<button @click="generateUserRegistrationLink(selectedUser)">Generate Registration Token</button>
<button @click="goOverview" v-if="info.is_global_admin" class="icon-btn" title="Overview">🏠</button>
<button @click="openOrg(selectedOrg)" v-if="selectedOrg" class="icon-btn" title="Back to Org"></button>
</div>
<p class="matrix-hint muted">Use the token dialog to register a new credential for the member.</p>
<RegistrationLinkModal
v-if="showRegModal"
:endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
:auto-copy="false"
@close="showRegModal = false"
@copied="onLinkCopied"
/>
</div>
<!-- Organization Detail Page -->
<div v-else-if="selectedOrg" class="card">
<h2 class="org-title" :title="selectedOrg.uuid">
<span class="org-name">{{ selectedOrg.display_name }}</span>
<button @click="updateOrg(selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization"></button>
</h2>
<div class="org-actions"></div>
<div class="matrix-wrapper">
<div class="matrix-scroll">
<div
class="perm-matrix-grid"
:style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + selectedOrg.roles.map(()=> '2.2rem').join(' ') + ' 2.2rem' }"
>
<!-- Headers -->
<div class="grid-head perm-head">Permission</div>
<div
v-for="r in selectedOrg.roles"
:key="'head-' + r.uuid"
class="grid-head role-head"
:title="r.display_name"
>
<span>{{ r.display_name }}</span>
<section class="section-block admin-section">
<div class="section-body admin-section-body">
<div v-if="loading" class="surface surface--tight">Loading</div>
<div v-else-if="error" class="surface surface--tight error">{{ error }}</div>
<template v-else>
<div v-if="!info?.authenticated" class="surface surface--tight">
<p>You must be authenticated.</p>
</div>
<div class="grid-head role-head add-role-head" title="Add role" @click="createRole(selectedOrg)" role="button"></div>
<!-- Data Rows -->
<template v-for="pid in selectedOrg.permissions" :key="pid">
<div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div>
<div
v-for="r in selectedOrg.roles"
:key="r.uuid + '-' + pid"
class="matrix-cell"
>
<input
type="checkbox"
:checked="r.permissions.includes(pid)"
@change="e => toggleRolePermission(r, pid, e.target.checked)"
/>
</div>
<div class="matrix-cell add-role-cell" />
</template>
</div>
</div>
<p class="matrix-hint muted">Toggle which permissions each role grants.</p>
</div>
<div class="roles-grid">
<div
v-for="r in selectedOrg.roles"
:key="r.uuid"
class="role-column"
@dragover="onRoleDragOver"
@drop="e => onRoleDrop(e, selectedOrg, r)"
>
<div class="role-header">
<strong class="role-name" :title="r.uuid">
<span>{{ r.display_name }}</span>
<button @click="updateRole(r)" class="icon-btn" aria-label="Edit role" title="Edit role"></button>
</strong>
<div class="role-actions">
<button @click="createUserInRole(selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user"></button>
<div v-else-if="!(info?.is_global_admin || info?.is_org_admin)" class="surface surface--tight">
<p>Insufficient permissions.</p>
</div>
<div v-else class="admin-panels">
<AdminOverview
v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)"
:info="info"
:orgs="orgs"
:permissions="permissions"
:permission-summary="permissionSummary"
@create-org="createOrg"
@open-org="openOrg"
@update-org="updateOrg"
@delete-org="deleteOrg"
@toggle-org-permission="toggleOrgPermission"
@open-dialog="openDialog"
@delete-permission="deletePermission"
@rename-permission-display="renamePermissionDisplay"
/>
<AdminUserDetail
v-else-if="selectedUser"
:selected-user="selectedUser"
:user-detail="userDetail"
:selected-org="selectedOrg"
:loading="loading"
:show-reg-modal="showRegModal"
@generate-user-registration-link="generateUserRegistrationLink"
@go-overview="goOverview"
@open-org="openOrg"
@on-user-name-saved="onUserNameSaved"
@edit-user-name="editUserName"
@close-reg-modal="showRegModal = false"
/>
<AdminOrgDetail
v-else-if="selectedOrg"
:selected-org="selectedOrg"
:permissions="permissions"
@update-org="updateOrg"
@create-role="createRole"
@update-role="updateRole"
@delete-role="deleteRole"
@create-user-in-role="createUserInRole"
@open-user="openUser"
@toggle-role-permission="toggleRolePermission"
@on-role-drag-over="onRoleDragOver"
@on-role-drop="onRoleDrop"
@on-user-drag-start="onUserDragStart"
/>
</div>
</div>
<template v-if="r.users.length > 0">
<ul class="user-list">
<li
v-for="u in r.users"
:key="u.uuid"
class="user-chip"
draggable="true"
@dragstart="e => onUserDragStart(e, u, selectedOrg.uuid)"
@click="openUser(u)"
:title="u.uuid"
>
<span class="name">{{ u.display_name }}</span>
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span>
</li>
</ul>
</template>
<div v-else class="empty-role">
<p class="empty-text muted">No members</p>
<button @click="deleteRole(r)" class="icon-btn delete-icon" aria-label="Delete empty role" title="Delete role"></button>
</div>
</div>
</div>
</section>
</div>
<div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card">
<h2>All Permissions</h2>
<div class="actions">
<button v-if="!showCreatePermission" @click="showCreatePermission = true">+ Create Permission</button>
<form v-else class="inline-form" @submit.prevent="submitCreatePermission">
<input v-model="newPermId" @input="sanitizeNewId" required :pattern="PERMISSION_ID_PATTERN" placeholder="permission id" title="Allowed: A-Za-z0-9:._~-" />
<input v-model="newPermName" required placeholder="display name" />
<button type="submit">Save</button>
<button type="button" @click="cancelCreatePermission">Cancel</button>
</form>
</div>
<div class="permission-grid">
<div class="perm-grid-head">Permission</div>
<div class="perm-grid-head">Orgs</div>
<div class="perm-grid-head center">Members</div>
<div class="perm-grid-head center">Actions</div>
<template v-for="p in [...permissions].sort((a,b)=> a.id.localeCompare(b.id))" :key="p.id">
<div class="perm-cell perm-name" :title="p.id">
<div class="perm-title-line">{{ p.display_name }}</div>
<div class="perm-id-line muted">{{ p.id }}</div>
</div>
<div class="perm-cell perm-orgs" :title="permissionSummary[p.id]?.orgs?.map(o=>o.display_name).join(', ') || ''">
<template v-if="permissionSummary[p.id]">
<span class="org-pill" v-for="o in permissionSummary[p.id].orgs" :key="o.uuid">
{{ o.display_name }}
<button class="pill-x" @click.stop="detachPermissionFromOrg(p.id, o.uuid)" aria-label="Remove">×</button>
</span>
</template>
<span class="org-add-wrapper">
<button
v-if="availableOrgsForPermission(p.id).length && addingOrgForPermission !== p.id"
class="add-org-btn"
@click.stop="addingOrgForPermission = p.id"
aria-label="Add organization"
title="Add organization"
></button>
<div
v-if="addingOrgForPermission === p.id"
class="org-add-menu"
tabindex="0"
@keydown.escape.stop.prevent="addingOrgForPermission = null"
>
<div class="org-add-list">
<button
v-for="o in availableOrgsForPermission(p.id)"
:key="o.uuid"
class="org-add-item"
@click.stop="attachPermissionToOrg(p.id, o.uuid); addingOrgForPermission = null"
>{{ o.display_name }}</button>
</div>
<div class="org-add-footer">
<button class="org-add-cancel" @click.stop="addingOrgForPermission = null" aria-label="Cancel">Cancel</button>
</div>
</div>
</span>
</div>
<div class="perm-cell perm-users center">{{ permissionSummary[p.id]?.userCount || 0 }}</div>
<div class="perm-cell perm-actions center">
<div class="perm-actions-inner" :class="{ editing: editingPermId === p.id }">
<div class="actions-view">
<button @click="renamePermissionDisplay(p)" class="icon-btn" aria-label="Change display name" title="Change display name"></button>
<button @click="startRenamePermissionId(p)" class="icon-btn" aria-label="Change id" title="Change id">🆔</button>
<button @click="deletePermission(p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission"></button>
</div>
<form class="inline-id-form overlay" @submit.prevent="submitRenamePermissionId(p)">
<input v-model="renameIdValue" @input="sanitizeRenameId" required :pattern="PERMISSION_ID_PATTERN" class="id-input" title="Allowed: A-Za-z0-9:._~-" />
<button type="submit" class="icon-btn" aria-label="Save"></button>
<button type="button" class="icon-btn" @click="cancelRenameId" aria-label="Cancel"></button>
</form>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
<StatusMessage />
<div v-if="dialog.type" class="modal-overlay" @keydown.esc.prevent.stop="closeDialog" tabindex="-1">
<div class="modal" role="dialog" aria-modal="true">
<h3 class="modal-title">
<template v-if="dialog.type==='org-create'">Create Organization</template>
<template v-else-if="dialog.type==='org-update'">Rename Organization</template>
<template v-else-if="dialog.type==='role-create'">Create Role</template>
<template v-else-if="dialog.type==='role-update'">Edit Role</template>
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
<template v-else-if="dialog.type==='perm-display'">Edit Permission Display</template>
<template v-else-if="dialog.type==='confirm'">Confirm</template>
</h3>
<form @submit.prevent="submitDialog" class="modal-form">
<template v-if="dialog.type==='org-create' || dialog.type==='org-update'">
<label>Name
<input v-model="dialog.data.name" :placeholder="dialog.type==='org-update'? dialog.data.org.display_name : 'Organization name'" required />
</label>
</template>
<template v-else-if="dialog.type==='role-create'">
<label>Role Name
<input v-model="dialog.data.name" placeholder="Role name" required />
</label>
</template>
<template v-else-if="dialog.type==='role-update'">
<label>Role Name
<input v-model="dialog.data.name" :placeholder="dialog.data.role.display_name" required />
</label>
<label>Permissions (comma separated)
<textarea v-model="dialog.data.perms" rows="2" placeholder="perm:a, perm:b"></textarea>
</label>
</template>
<template v-else-if="dialog.type==='user-create'">
<p class="small muted">Role: {{ dialog.data.role.display_name }}</p>
<label>Display Name
<input v-model="dialog.data.name" placeholder="User display name" required />
</label>
</template>
<template v-else-if="dialog.type==='perm-display'">
<p class="small muted">ID: {{ dialog.data.permission.id }}</p>
<label>Display Name
<input v-model="dialog.data.display_name" :placeholder="dialog.data.permission.display_name" required />
</label>
</template>
<template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p>
</template>
<div v-if="dialog.error" class="error small">{{ dialog.error }}</div>
<div class="modal-actions">
<button type="submit" :disabled="dialog.busy">{{ dialog.type==='confirm' ? 'OK' : 'Save' }}</button>
<button type="button" @click="closeDialog" :disabled="dialog.busy">Cancel</button>
</div>
</form>
</div>
</section>
</main>
<AdminDialogs
:dialog="dialog"
:permission-id-pattern="PERMISSION_ID_PATTERN"
@submit-dialog="submitDialog"
@close-dialog="closeDialog"
/>
</div>
</template>
<style scoped>
.container { max-width: 960px; margin: 2rem auto; padding: 0 1rem; }
.subtitle { color: #888 }
.card { margin: 1rem 0; padding: 1rem; border: 1px solid #eee; border-radius: 8px; }
.error { color: #a00 }
.actions { margin-bottom: .5rem }
.org { border-top: 1px dashed #eee; padding: .5rem 0 }
.org-header { display: flex; gap: .5rem; align-items: baseline }
.user-item { display: flex; gap: .5rem; margin: .15rem 0 }
.users-table { width: 100%; border-collapse: collapse; margin-top: .25rem; }
.users-table th, .users-table td { padding: .25rem .4rem; text-align: left; border-bottom: 1px solid #eee; font-weight: normal; }
.users-table th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: #555; }
.users-table tbody tr:hover { background: #fafafa; }
.org-actions, .role-actions, .perm-actions { display: flex; gap: .5rem; margin: .25rem 0 }
.muted { color: #666 }
.small { font-size: .9em }
.pill-list { display: flex; flex-wrap: wrap; gap: .25rem }
.pill { background: #f3f3f3; border: 1px solid #e2e2e2; border-radius: 999px; padding: .1rem .5rem; display: inline-flex; align-items: center; gap: .25rem }
.pill-x { background: transparent; border: none; color: #900; cursor: pointer }
button { padding: .25rem .5rem; border-radius: 6px; border: 1px solid #ddd; background: #fff; cursor: pointer }
button:hover { background: #f7f7f7 }
/* Avoid global button 100% width from frontend main styles */
button, .perm-actions button, .org-actions button, .role-actions button { width: auto; }
.roles-grid { display: flex; flex-wrap: wrap; gap: 1rem; align-items: stretch; padding: .5rem 0; }
.role-column { background: #fafafa; border: 1px solid #eee; border-radius: 8px; padding: .5rem; min-width: 200px; flex: 1 1 240px; display: flex; flex-direction: column; max-width: 300px; }
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .25rem }
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .25rem; flex: 1 1 auto; }
.user-chip { background: #fff; border: 1px solid #ddd; border-radius: 6px; padding: .25rem .4rem; display: flex; justify-content: space-between; gap: .5rem; cursor: grab; }
.user-chip:active { cursor: grabbing }
.user-chip .name { font-weight: 500 }
.user-chip .meta { font-size: .65rem; color: #666 }
.role-column.drag-over { outline: 2px dashed #66a; }
.org-table { width: 100%; border-collapse: collapse; }
.org-table th, .org-table td { padding: .4rem .5rem; border-bottom: 1px solid #eee; text-align: left; }
.org-table th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: #555; }
.org-table a { text-decoration: none; color: #0366d6; }
.org-table a:hover { text-decoration: underline; }
.nav-link { font-size: .6em; margin-left: .5rem; background: #eee; padding: .25em .6em; border-radius: 999px; border: 1px solid #ccc; text-decoration: none; }
.nav-link:hover { background: #ddd; }
.back-link { font-size: .5em; margin-left: .75rem; text-decoration: none; background: #eee; padding: .25em .6em; border-radius: 999px; border: 1px solid #ccc; vertical-align: middle; line-height: 1.2; }
.back-link:hover { background: #ddd; }
.matrix-wrapper { margin: 1rem 0; text-align: left; }
.matrix-scroll { overflow-x: auto; text-align: left; }
.perm-matrix-grid { display: inline-grid; gap: 0; align-items: stretch; margin-right: 4rem; }
.perm-matrix-grid > * { background: #fff; border: none; padding: .35rem .4rem; font-size: .75rem; }
.perm-matrix-grid .grid-head { background: transparent; border: none; font-size: .65rem; letter-spacing: .05em; font-weight: 600; text-transform: uppercase; display: flex; justify-content: center; align-items: flex-end; padding-bottom: .25rem; }
.perm-matrix-grid .perm-head { justify-content: flex-start; align-items: flex-end; }
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: .6rem; line-height: 1; }
.perm-matrix-grid .perm-name { font-weight: 500; white-space: nowrap; text-align: left; }
.perm-matrix-grid .matrix-cell { display: flex; justify-content: center; align-items: center; }
.perm-matrix-grid .matrix-cell input { cursor: pointer; }
.matrix-hint { font-size: .7rem; margin-top: .25rem; }
/* Add role column styles */
.add-role-head { cursor: pointer; color: #2a6; font-size: 1rem; display:flex; justify-content:center; align-items:flex-end; }
.add-role-head:hover { color:#1c4; }
/* Removed add-role placeholder styles */
/* Inline organization title with icon */
.org-title { display: flex; align-items: center; gap: .4rem; }
.org-title .org-name { flex: 0 1 auto; }
/* Plus button for adding users */
.plus-btn { background: none; border: none; font-size: 1.15rem; line-height: 1; padding: 0 .1rem; cursor: pointer; opacity: .6; }
.plus-btn:hover, .plus-btn:focus { opacity: 1; outline: none; }
.plus-btn:focus-visible { outline: 2px solid #555; outline-offset: 2px; }
.empty-role { display: flex; flex-direction: column; gap: .4rem; align-items: flex-start; padding: .35rem .25rem; /* removed flex grow & width for natural size */ }
.empty-role .empty-text { font-size: .7rem; margin: 0; }
.delete-icon { color: #c00; }
.delete-icon:hover, .delete-icon:focus { color: #ff0000; }
.user-detail .user-link-box { margin-top: .75rem; font-size: .7rem; background: #fff; border: 1px dashed #ccc; padding: .5rem; border-radius: 6px; cursor: pointer; word-break: break-all; }
.user-detail .user-link-box:hover { background: #f9f9f9; }
.user-detail .user-link-box .expires { font-size: .6rem; margin-top: .25rem; color: #555; }
/* Minimal icon button for rename/edit actions */
.icon-btn { background: none; border: none; padding: 0 .15rem; margin-left: .15rem; cursor: pointer; font-size: .8rem; line-height: 1; opacity: .55; vertical-align: middle; }
.icon-btn:hover, .icon-btn:focus { opacity: .95; outline: none; }
.icon-btn:focus-visible { outline: 2px solid #555; outline-offset: 2px; }
.icon-btn:active { transform: translateY(1px); }
.org-title { display: flex; align-items: baseline; gap: .25rem; }
.role-name { display: inline-flex; align-items: center; gap: .15rem; font-weight: 600; }
.perm-name-line { display: flex; align-items: center; gap: .15rem; }
.user-meta { margin-top: .25rem; }
.cred-title { margin-top: .75rem; font-size: .85rem; }
.cred-list { list-style: none; padding: 0; margin: .25rem 0 .5rem; display: flex; flex-direction: column; gap: .35rem; }
.cred-item { background: #fff; border: 1px solid #eee; border-radius: 6px; padding: .35rem .5rem; font-size: .65rem; }
.cred-line { display: flex; flex-direction: column; gap: .15rem; }
.cred-line .dates { color: #555; font-size: .6rem; }
/* Permission grid */
.permission-grid { display: grid; grid-template-columns: minmax(220px,2fr) minmax(160px,3fr) 70px 90px; gap: 2px; margin-top: .5rem; }
.permission-grid .perm-grid-head { font-size: .6rem; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; padding: .35rem .4rem; background: #f3f3f3; border: 1px solid #e1e1e1; }
.permission-grid .perm-cell { background: #fff; border: 1px solid #eee; padding: .35rem .4rem; font-size: .7rem; display: flex; align-items: center; gap: .4rem; }
.permission-grid .perm-name { flex-direction: row; flex-wrap: wrap; }
.permission-grid .perm-name { flex-direction: column; align-items: flex-start; gap:2px; }
.permission-grid .perm-title-line { font-weight:600; line-height:1.1; }
.permission-grid .perm-id-line { font-size:.55rem; line-height:1.1; word-break:break-all; }
.permission-grid .center { justify-content: center; }
.permission-grid .perm-actions { gap: .25rem; }
.permission-grid .perm-actions .icon-btn { font-size: .9rem; }
/* Inline edit overlay to avoid layout shift */
.perm-actions-inner { position: relative; display:flex; width:100%; justify-content:center; }
.perm-actions-inner .inline-id-form.overlay { position:absolute; inset:0; display:none; align-items:center; justify-content:center; gap:.25rem; background:rgba(255,255,255,.9); backdrop-filter:blur(2px); padding:0 .15rem; }
.perm-actions-inner.editing .inline-id-form.overlay { display:inline-flex; }
.perm-actions-inner.editing .actions-view { visibility:hidden; }
/* Inline forms */
.inline-form, .inline-id-form { display:inline-flex; gap:.25rem; align-items:center; }
.inline-form input, .inline-id-form input { padding:.25rem .4rem; font-size:.6rem; border:1px solid #ccc; border-radius:4px; }
.inline-form button, .inline-id-form button { font-size:.6rem; padding:.3rem .5rem; }
.inline-id-form .id-input { width:120px; }
/* Modal */
.modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.4); display:flex; justify-content:center; align-items:flex-start; padding-top:8vh; z-index:200; }
.modal { background:#fff; border-radius:10px; padding:1rem 1.1rem; width: min(420px, 90%); box-shadow:0 10px 30px rgba(0,0,0,.25); animation:pop .18s ease; }
@keyframes pop { from { transform:translateY(10px); opacity:0 } to { transform:translateY(0); opacity:1 } }
.modal-title { margin:0 0 .65rem; font-size:1rem; }
.modal-form { display:flex; flex-direction:column; gap:.65rem; }
.modal-form label { display:flex; flex-direction:column; font-size:.65rem; gap:.25rem; font-weight:600; }
.modal-form input, .modal-form textarea { border:1px solid #ccc; border-radius:6px; padding:.45rem .55rem; font-size:.7rem; font-weight:400; font-family:inherit; }
.modal-form textarea { resize:vertical; }
.modal-actions { display:flex; gap:.5rem; justify-content:flex-end; margin-top:.25rem; }
.modal-actions button { font-size:.65rem; }
/* Org pill editing */
.perm-orgs { flex-wrap: wrap; gap: .25rem; }
.perm-orgs .org-pill { background:#eef4ff; border:1px solid #d0dcf0; padding:2px 6px; border-radius:999px; font-size:.55rem; display:inline-flex; align-items:center; gap:4px; }
.perm-orgs .org-pill .pill-x { background:none; border:none; cursor:pointer; font-size:.7rem; line-height:1; padding:0; margin:0; color:#555; }
.perm-orgs .org-pill .pill-x:hover { color:#c00; }
.add-org-btn { background:none; border:none; cursor:pointer; font-size:.7rem; padding:0 2px; line-height:1; opacity:.55; display:inline; }
.add-org-btn:hover, .add-org-btn:focus { opacity:1; }
.add-org-btn:focus-visible { outline:2px solid #555; outline-offset:2px; }
.org-add-wrapper { position:relative; display:inline-block; }
.org-add-menu { position:absolute; top:100%; left:0; z-index:20; margin-top:4px; min-width:160px; background:#fff; border:1px solid #e2e6ea; border-radius:6px; padding:.3rem .35rem; box-shadow:0 4px 10px rgba(0,0,0,.08); display:flex; flex-direction:column; gap:.25rem; font-size:.6rem; }
.org-add-menu:before { content:""; position:absolute; top:-5px; left:10px; width:8px; height:8px; background:#fff; border-left:1px solid #e2e6ea; border-top:1px solid #e2e6ea; transform:rotate(45deg); }
.org-add-list { display:flex; flex-direction:column; gap:0; max-height:180px; overflow-y:auto; scrollbar-width:thin; }
.org-add-item { background:transparent; border:none; padding:.25rem .4rem; font-size:.6rem; border-radius:4px; cursor:pointer; line-height:1.1; text-align:left; width:100%; color:#222; }
.org-add-item:hover, .org-add-item:focus { background:#f2f5f9; }
.org-add-item:active { background:#e6ebf0; }
.org-add-footer { margin-top:.25rem; display:flex; justify-content:flex-end; }
.org-add-cancel { background:transparent; border:none; font-size:.55rem; padding:.15rem .35rem; cursor:pointer; color:#666; border-radius:4px; }
.org-add-cancel:hover, .org-add-cancel:focus { background:#f2f5f9; color:#222; }
.org-add-cancel:active { background:#e6ebf0; }
.view-admin { padding-bottom: var(--space-3xl); }
.view-header { display: flex; flex-direction: column; gap: var(--space-sm); }
.admin-section { margin-top: var(--space-xl); }
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
</style>

View File

@@ -0,0 +1,128 @@
<script setup>
import { ref, watch, nextTick } from 'vue'
import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
const props = defineProps({
dialog: Object,
PERMISSION_ID_PATTERN: String
})
const emit = defineEmits(['submitDialog', 'closeDialog'])
const nameInput = ref(null)
const displayNameInput = ref(null)
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
watch(() => props.dialog.type, (newType) => {
if (newType === 'org-create') {
nextTick(() => {
nameInput.value?.focus()
})
} else if (newType === 'perm-display' || newType === 'perm-create') {
nextTick(() => {
displayNameInput.value?.focus()
if (newType === 'perm-display') {
displayNameInput.value?.select()
}
})
}
})
</script>
<template>
<Modal v-if="dialog.type" @close="$emit('closeDialog')">
<h3 class="modal-title">
<template v-if="dialog.type==='org-create'">Create Organization</template>
<template v-else-if="dialog.type==='org-update'">Rename Organization</template>
<template v-else-if="dialog.type==='role-create'">Create Role</template>
<template v-else-if="dialog.type==='role-update'">Edit Role</template>
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission Display' }}</template>
<template v-else-if="dialog.type==='confirm'">Confirm</template>
</h3>
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
<template v-if="dialog.type==='org-create'">
<label>Name
<input ref="nameInput" v-model="dialog.data.name" required />
</label>
</template>
<template v-else-if="dialog.type==='org-update'">
<NameEditForm
label="Organization Name"
v-model="dialog.data.name"
:busy="dialog.busy"
:error="dialog.error"
@cancel="$emit('closeDialog')"
/>
</template>
<template v-else-if="dialog.type==='role-create'">
<label>Role Name
<input v-model="dialog.data.name" placeholder="Role name" required />
</label>
</template>
<template v-else-if="dialog.type==='role-update'">
<NameEditForm
label="Role Name"
v-model="dialog.data.name"
:busy="dialog.busy"
:error="dialog.error"
@cancel="$emit('closeDialog')"
/>
</template>
<template v-else-if="dialog.type==='user-create'">
<p class="small muted">Role: {{ dialog.data.role.display_name }}</p>
<label>Display Name
<input v-model="dialog.data.name" placeholder="User display name" required />
</label>
</template>
<template v-else-if="dialog.type==='user-update-name'">
<NameEditForm
label="Display Name"
v-model="dialog.data.name"
:busy="dialog.busy"
:error="dialog.error"
@cancel="$emit('closeDialog')"
/>
</template>
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">
<label>Display Name
<input ref="displayNameInput" v-model="dialog.data.display_name" required />
</label>
<label>Permission ID
<input v-model="dialog.data.id" :placeholder="dialog.type === 'perm-create' ? 'yourapp:login' : dialog.data.permission.id" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" />
</label>
<p class="small muted">The permission ID is used for permission checks in the application. Changing it may break deployed applications that reference this permission.</p>
</template>
<template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p>
</template>
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions">
<button
type="button"
class="btn-secondary"
@click="$emit('closeDialog')"
:disabled="dialog.busy"
>
Cancel
</button>
<button
type="submit"
class="btn-primary"
:disabled="dialog.busy"
>
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
</button>
</div>
</form>
</Modal>
</template>
<style scoped>
.error { color: var(--color-danger-text); }
.small { font-size: 0.9rem; }
.muted { color: var(--color-text-muted); }
</style>

View File

@@ -0,0 +1,157 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
selectedOrg: Object,
permissions: Array
})
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'onRoleDragOver', 'onRoleDrop', 'onUserDragStart'])
const sortedRoles = computed(() => {
return [...props.selectedOrg.roles].sort((a, b) => {
const nameA = a.display_name.toLowerCase()
const nameB = b.display_name.toLowerCase()
if (nameA !== nameB) {
return nameA.localeCompare(nameB)
}
return a.uuid.localeCompare(b.uuid)
})
})
function permissionDisplayName(id) {
return props.permissions.find(p => p.id === id)?.display_name || id
}
function toggleRolePermission(role, pid, checked) {
emit('toggleRolePermission', role, pid, checked)
}
</script>
<template>
<h2 class="org-title" :title="selectedOrg.uuid">
<span class="org-name">{{ selectedOrg.display_name }}</span>
<button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization"></button>
</h2>
<div class="matrix-wrapper">
<div class="matrix-scroll">
<div
class="perm-matrix-grid"
:style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + sortedRoles.map(()=> '2.2rem').join(' ') + ' 2.2rem' }"
>
<div class="grid-head perm-head">Permission</div>
<div
v-for="r in sortedRoles"
:key="'head-' + r.uuid"
class="grid-head role-head"
:title="r.display_name"
>
<span>{{ r.display_name }}</span>
</div>
<div class="grid-head role-head add-role-head" title="Add role" @click="$emit('createRole', selectedOrg)" role="button"></div>
<template v-for="pid in selectedOrg.permissions" :key="pid">
<div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div>
<div
v-for="r in sortedRoles"
:key="r.uuid + '-' + pid"
class="matrix-cell"
>
<input
type="checkbox"
:checked="r.permissions.includes(pid)"
@change="e => toggleRolePermission(r, pid, e.target.checked)"
/>
</div>
<div class="matrix-cell add-role-cell" />
</template>
</div>
</div>
<p class="matrix-hint muted">Toggle which permissions each role grants.</p>
</div>
<div class="roles-grid">
<div
v-for="r in sortedRoles"
:key="r.uuid"
class="role-column"
@dragover="$emit('onRoleDragOver', $event)"
@drop="e => $emit('onRoleDrop', e, selectedOrg, r)"
>
<div class="role-header">
<strong class="role-name" :title="r.uuid">
<span>{{ r.display_name }}</span>
<button @click="$emit('updateRole', r)" class="icon-btn" aria-label="Edit role" title="Edit role"></button>
</strong>
<div class="role-actions">
<button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user"></button>
</div>
</div>
<template v-if="r.users.length > 0">
<ul class="user-list">
<li
v-for="u in r.users.slice().sort((a, b) => {
const nameA = a.display_name.toLowerCase()
const nameB = b.display_name.toLowerCase()
if (nameA !== nameB) {
return nameA.localeCompare(nameB)
}
return a.uuid.localeCompare(b.uuid)
})"
:key="u.uuid"
class="user-chip"
draggable="true"
@dragstart="e => $emit('onUserDragStart', e, u, selectedOrg.uuid)"
@click="$emit('openUser', u)"
:title="u.uuid"
>
<span class="name">{{ u.display_name }}</span>
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span>
</li>
</ul>
</template>
<div v-else class="empty-role">
<p class="empty-text muted">No members</p>
<button @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete empty role" title="Delete role"></button>
</div>
</div>
</div>
</template>
<style scoped>
.card.surface { padding: var(--space-lg); }
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); }
.org-name { font-size: 1.5rem; font-weight: 600; color: var(--color-heading); }
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
.matrix-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); }
.matrix-scroll { overflow-x: auto; }
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
.perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; }
.perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
.perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; }
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
.perm-matrix-grid .add-role-head { cursor: pointer; }
.perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.roles-grid { display: flex; gap: var(--space-lg); margin-top: var(--space-lg); }
.role-column { flex: 1; min-width: 200px; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--space-md); }
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
.role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); }
.role-actions { display: flex; gap: var(--space-xs); }
.plus-btn { background: var(--color-accent-soft); color: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: 0.25rem 0.45rem; font-size: 1.1rem; cursor: pointer; }
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); }
.user-chip { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
.user-chip .meta { font-size: 0.7rem; color: var(--color-text-muted); }
.empty-role { border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-sm); display: flex; flex-direction: column; gap: var(--space-xs); align-items: flex-start; }
.empty-text { margin: 0; }
.delete-icon { color: var(--color-danger); }
.delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); }
.muted { color: var(--color-text-muted); }
@media (max-width: 720px) {
.roles-grid { flex-direction: column; }
}
</style>

View File

@@ -0,0 +1,165 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
info: Object,
orgs: Array,
permissions: Array,
permissionSummary: Object
})
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay'])
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
const nameCompare = a.display_name.localeCompare(b.display_name)
return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid)
}))
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.id.localeCompare(b.id)))
function permissionDisplayName(id) {
return props.permissions.find(p => p.id === id)?.display_name || id
}
function getRoleNames(org) {
return org.roles
.slice()
.sort((a, b) => a.display_name.localeCompare(b.display_name))
.map(r => r.display_name)
.join(', ')
}
</script>
<template>
<div class="permissions-section">
<h2>{{ info.is_global_admin ? 'Organizations' : 'Your Organizations' }}</h2>
<div class="actions">
<button v-if="info.is_global_admin" @click="$emit('createOrg')">+ Create Org</button>
</div>
<table class="org-table">
<thead>
<tr>
<th>Name</th>
<th>Roles</th>
<th>Members</th>
<th v-if="info.is_global_admin">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="o in sortedOrgs" :key="o.uuid">
<td>
<a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.display_name }}</a>
<button v-if="info.is_global_admin || info.is_org_admin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization"></button>
</td>
<td class="role-names">{{ getRoleNames(o) }}</td>
<td class="center">{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td>
<td v-if="info.is_global_admin" class="center">
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization"></button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="info.is_global_admin" class="permissions-section">
<h2>Permissions</h2>
<div class="matrix-wrapper">
<div class="matrix-scroll">
<div
class="perm-matrix-grid"
:style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + sortedOrgs.map(()=> '2.2rem').join(' ') }"
>
<div class="grid-head perm-head">Permission</div>
<div
v-for="o in sortedOrgs"
:key="'head-' + o.uuid"
class="grid-head org-head"
:title="o.display_name"
>
<span>{{ o.display_name }}</span>
</div>
<template v-for="p in sortedPermissions" :key="p.id">
<div class="perm-name" :title="p.id">
<span class="display-text">{{ p.display_name }}</span>
</div>
<div
v-for="o in sortedOrgs"
:key="o.uuid + '-' + p.id"
class="matrix-cell"
>
<input
type="checkbox"
:checked="o.permissions.includes(p.id)"
@change="e => $emit('toggleOrgPermission', o, p.id, e.target.checked)"
/>
</div>
</template>
</div>
</div>
<p class="matrix-hint muted">Toggle which permissions each organization can grant to its members.</p>
</div>
<div class="actions">
<button v-if="info.is_global_admin" @click="$emit('openDialog', 'perm-create', { display_name: '', id: '' })">+ Create Permission</button>
</div>
<table class="org-table">
<thead>
<tr>
<th scope="col">Permission</th>
<th scope="col" class="center">Members</th>
<th scope="col" class="center">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="p in sortedPermissions" :key="p.id">
<td class="perm-name-cell">
<div class="perm-title">
<span class="display-text">{{ p.display_name }}</span>
<button @click="$emit('renamePermissionDisplay', p)" class="icon-btn edit-display-btn" aria-label="Edit display name" title="Edit display name"></button>
</div>
<div class="perm-id-info">
<span class="id-text">{{ p.id }}</span>
</div>
</td>
<td class="perm-members center">{{ permissionSummary[p.id]?.userCount || 0 }}</td>
<td class="perm-actions center">
<button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission"></button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.permissions-section { margin-bottom: var(--space-xl); }
.permissions-section h2 { margin-bottom: var(--space-md); }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
.actions button { width: auto; }
.org-table a { text-decoration: none; color: var(--color-link); }
.org-table a:hover { text-decoration: underline; }
.org-table .center { width: 6rem; min-width: 6rem; }
.org-table .role-names { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.perm-name-cell { display: flex; flex-direction: column; gap: 0.3rem; }
.perm-title { font-weight: 600; color: var(--color-heading); }
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); }
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
.delete-icon { color: var(--color-danger); }
.delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); }
.matrix-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); }
.matrix-scroll { overflow-x: auto; }
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
.perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; }
.perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
.perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .org-head { display: flex; align-items: flex-end; justify-content: center; }
.perm-matrix-grid .org-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
.perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.display-text { margin-right: var(--space-xs); }
.edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; }
.edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); }
.perm-actions { text-align: center; }
.center { text-align: center; }
.muted { color: var(--color-text-muted); }
</style>

View File

@@ -0,0 +1,118 @@
<script setup>
import { ref } from 'vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import CredentialList from '@/components/CredentialList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import SessionList from '@/components/SessionList.vue'
import { useAuthStore } from '@/stores/auth'
const props = defineProps({
selectedUser: Object,
userDetail: Object,
selectedOrg: Object,
loading: Boolean,
showRegModal: Boolean
})
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName'])
const authStore = useAuthStore()
function onLinkCopied() {
authStore.showMessage('Link copied to clipboard!')
}
function handleEditName() {
emit('editUserName', props.selectedUser)
}
function handleDelete(credential) {
fetch(`/auth/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/credentials/${credential.credential_uuid}`, { method: 'DELETE' })
.then(res => res.json())
.then(data => {
if (data.status === 'ok') {
emit('onUserNameSaved') // Reuse to refresh user detail
} else {
console.error('Failed to delete credential', data)
}
})
.catch(err => console.error('Delete credential error', err))
}
</script>
<template>
<div class="user-detail">
<UserBasicInfo
v-if="userDetail && !userDetail.error"
:name="userDetail.display_name || selectedUser.display_name"
:visits="userDetail.visits"
:created-at="userDetail.created_at"
:last-seen="userDetail.last_seen"
:loading="loading"
:org-display-name="userDetail.org.display_name"
:role-name="userDetail.role"
:update-endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
@saved="$emit('onUserNameSaved')"
@edit-name="handleEditName"
/>
<div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
<template v-if="userDetail && !userDetail.error">
<div class="registration-actions">
<button
class="btn-secondary reg-token-btn"
@click="$emit('generateUserRegistrationLink', selectedUser)"
:disabled="loading"
>Generate Registration Token</button>
<p class="matrix-hint muted">
Generate a one-time registration link so this user can register or add another passkey.
Copy the link from the dialog and send it to the user, or have the user scan the QR code on their device.
</p>
</div>
<section class="section-block" data-section="registered-passkeys">
<div class="section-header">
<h2>Registered Passkeys</h2>
</div>
<div class="section-body">
<CredentialList
:credentials="userDetail.credentials"
:aaguid-info="userDetail.aaguid_info"
:allow-delete="true"
@delete="handleDelete"
/>
</div>
</section>
<SessionList
:sessions="userDetail.sessions || []"
:allow-terminate="false"
:empty-message="'This user has no active sessions.'"
:section-description="'View the active sessions for this user.'"
/>
</template>
<div class="actions ancillary-actions">
<button v-if="selectedOrg" @click="$emit('openOrg', selectedOrg)" class="icon-btn" title="Back to Org"></button>
</div>
<RegistrationLinkModal
v-if="showRegModal"
:endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
:auto-copy="false"
:user-name="userDetail?.display_name || selectedUser.display_name"
@close="$emit('closeRegModal')"
@copied="onLinkCopied"
/>
</div>
</template>
<style scoped>
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
.ancillary-actions { margin-top: -0.5rem; }
.reg-token-btn { align-self: flex-start; }
.registration-actions { display: flex; flex-direction: column; gap: 0.5rem; }
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
.error { color: var(--color-danger-text); }
.small { font-size: 0.9rem; }
.muted { color: var(--color-text-muted); }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
<script setup>
import { computed } from 'vue'
// Props:
// entries: Array<{ label:string, href:string }>
// showHome: include leading home icon (defaults true)
// homeHref: home link target (default '/')
const props = defineProps({
entries: { type: Array, default: () => [] },
showHome: { type: Boolean, default: true },
homeHref: { type: String, default: '/' }
})
const crumbs = computed(() => {
const base = props.showHome ? [{ label: '🏠', href: props.homeHref }] : []
return [...base, ...props.entries]
})
</script>
<template>
<nav class="breadcrumbs" aria-label="Breadcrumb" v-if="crumbs.length">
<ol>
<li v-for="(c, idx) in crumbs" :key="idx">
<a :href="c.href">{{ c.label }}</a>
<span v-if="idx < crumbs.length - 1" class="sep"> </span>
</li>
</ol>
</nav>
</template>
<style scoped>
.breadcrumbs { margin: .25rem 0 .5rem; line-height:1.2; color: var(--color-text-muted); }
.breadcrumbs ol { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: .25rem; }
.breadcrumbs li { display: inline-flex; align-items: center; gap: .25rem; font-size: .9rem; }
.breadcrumbs a { text-decoration: none; color: var(--color-link); padding: 0 .25rem; border-radius:4px; transition: color 0.2s ease, background 0.2s ease; }
.breadcrumbs a:hover, .breadcrumbs a:focus-visible { text-decoration: underline; color: var(--color-link-hover); outline: none; }
.breadcrumbs .sep { color: var(--color-text-muted); margin: 0; }
</style>

View File

@@ -2,14 +2,14 @@
<div class="credential-list">
<div v-if="loading"><p>Loading credentials...</p></div>
<div v-else-if="!credentials?.length"><p>No passkeys found.</p></div>
<div v-else>
<template v-else>
<div
v-for="credential in credentials"
:key="credential.credential_uuid"
:class="['credential-item', { 'current-session': credential.is_current_session }]"
:class="['credential-item', { 'current-session': credential.is_current_session } ]"
>
<div class="credential-header">
<div class="credential-icon">
<div class="item-top">
<div class="item-icon">
<img
v-if="getCredentialAuthIcon(credential)"
:src="getCredentialAuthIcon(credential)"
@@ -20,26 +20,30 @@
>
<span v-else class="auth-emoji">🔑</span>
</div>
<div class="credential-info">
<h4>{{ getCredentialAuthName(credential) }}</h4>
</div>
<div class="credential-dates">
<span class="date-label">Created:</span>
<span class="date-value">{{ formatDate(credential.created_at) }}</span>
<span class="date-label" v-if="credential.last_used">Last used:</span>
<span class="date-value" v-if="credential.last_used">{{ formatDate(credential.last_used) }}</span>
</div>
<div class="credential-actions" v-if="allowDelete">
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
<div class="item-actions">
<span v-if="credential.is_current_session" class="badge badge-current">Current</span>
<button
v-if="allowDelete"
@click="$emit('delete', credential)"
class="btn-delete-credential"
class="btn-card-delete"
:disabled="credential.is_current_session"
:title="credential.is_current_session ? 'Cannot delete current session credential' : 'Delete passkey'"
>🗑</button>
</div>
</div>
<div class="item-details">
<div class="credential-dates">
<span class="date-label">Created:</span>
<span class="date-value">{{ formatDate(credential.created_at) }}</span>
<span class="date-label">Last used:</span>
<span class="date-value">{{ formatDate(credential.last_used) }}</span>
<span class="date-label">Last verified:</span>
<span class="date-value">{{ formatDate(credential.last_verified) }}</span>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
@@ -67,18 +71,3 @@ const getCredentialAuthIcon = (credential) => {
return info[iconKey] || null
}
</script>
<style scoped>
.credential-list { display: flex; flex-direction: column; gap: .75rem; margin-top: .5rem; }
.credential-item { border: 1px solid #ddd; border-radius: 8px; padding: .5rem .75rem; background: #fff; }
.credential-header { display: flex; align-items: center; gap: 1rem; }
.credential-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; }
.auth-icon { border-radius: 6px; }
.credential-info { flex: 1 1 auto; }
.credential-info h4 { margin: 0; font-size: .9rem; }
.credential-dates { display: grid; grid-auto-flow: column; gap: .4rem; font-size: .65rem; align-items: center; }
.date-label { font-weight: 600; }
.credential-actions { margin-left: auto; }
.btn-delete-credential { background: none; border: none; cursor: pointer; font-size: .9rem; }
.btn-delete-credential:disabled { opacity: .3; cursor: not-allowed; }
</style>

View File

@@ -1,67 +1,73 @@
<template>
<div class="container">
<div class="view active">
<h1>📱 Add Another Device</h1>
<div class="device-link-section">
<div class="qr-container">
<a :href="url" id="deviceLinkText" @click="copyLink">
<canvas id="qrCode" class="qr-code"></canvas>
<p v-if="url">
{{ url.replace(/^[^:]+:\/\//, '') }}
</p>
<p v-else>
<em>Generating link...</em>
</p>
</a>
<p>
<strong>Scan and visit the URL on another device.</strong><br>
<small> Expires in 24 hours and can only be used once.</small>
</p>
</div>
<section class="view-root view-device-link">
<div class="view-content view-content--narrow">
<header class="view-header">
<h1>📱 Add Another Device</h1>
<p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p>
</header>
<RegistrationLinkModal
inline
:endpoint="'/auth/api/user/create-link'"
:user-name="userName"
:auto-copy="false"
:prefix-copy-with-user-name="!!userName"
show-close-in-inline
@copied="onCopied"
/>
<div class="button-row" style="margin-top:1rem;">
<button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
</div>
<button @click="authStore.currentView = 'profile'" class="btn-secondary">
Back to Profile
</button>
</div>
</div>
</section>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import QRCode from 'qrcode/lib/browser'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
const authStore = useAuthStore()
const url = ref(null)
const copyLink = async (event) => {
event.preventDefault()
if (url.value) {
await navigator.clipboard.writeText(url.value)
authStore.showMessage('Link copied to clipboard!')
authStore.currentView = 'profile'
}
const userName = ref(null)
const onCopied = () => {
authStore.showMessage('Link copied to clipboard!', 'success', 2500)
authStore.currentView = 'profile'
}
onMounted(async () => {
try {
const response = await fetch('/auth/api/create-link', { method: 'POST' })
const result = await response.json()
if (result.detail) throw new Error(result.detail)
url.value = result.url
// Generate QR code
const qrCodeElement = document.getElementById('qrCode')
if (qrCodeElement) {
QRCode.toCanvas(qrCodeElement, url.value, {scale: 8 }, error => {
if (error) console.error('Failed to generate QR code:', error)
})
}
} catch (error) {
authStore.showMessage(`Failed to create device link: ${error.message}`, 'error')
authStore.currentView = 'profile'
}
// Extract optional admin-provided query parameters (?user=Name&emoji=😀)
const params = new URLSearchParams(location.search)
const qUser = params.get('user')
if (qUser) userName.value = qUser.trim()
})
</script>
<style scoped>
.view-content--narrow {
max-width: 540px;
}
.view-lede {
margin: 0;
color: var(--color-text-muted);
}
.qr-link {
text-decoration: none;
color: var(--color-text);
}
.button-row {
justify-content: flex-start;
}
@media (max-width: 720px) {
.button-row {
flex-direction: column;
}
.button-row button {
width: 100%;
}
}
</style>

View File

@@ -1,41 +0,0 @@
<template>
<div class="container">
<div class="view active">
<h1>🔐 {{ (authStore.settings?.rp_name || 'Passkey') + ' Login' }}</h1>
<form @submit.prevent="handleLogin">
<button
type="submit"
class="btn-primary"
:disabled="authStore.isLoading"
>
{{ authStore.isLoading ? 'Authenticating...' : 'Login with Your Device' }}
</button>
</form>
</div>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const handleLogin = async () => {
try {
console.log('Login button clicked')
authStore.showMessage('Starting authentication...', 'info')
await authStore.authenticate()
authStore.showMessage('Authentication successful!', 'success', 2000)
if (authStore.restrictedMode) {
// Restricted mode: reload so the app re-mounts and selectView() applies (will become permission denied)
location.reload()
} else if (location.pathname === '/auth/') {
authStore.currentView = 'profile'
} else {
location.reload()
}
} catch (error) {
authStore.showMessage(error.message, 'error')
}
}
</script>

View File

@@ -0,0 +1,93 @@
<template>
<div class="modal-overlay" @keydown.esc="$emit('close')" tabindex="-1">
<div class="modal" role="dialog" aria-modal="true">
<slot />
</div>
</div>
</template>
<script setup>
defineEmits(['close'])
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(.1rem);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
padding: calc(var(--space-lg) - var(--space-xs));
max-width: 500px;
width: min(500px, 90vw);
max-height: 90vh;
overflow-y: auto;
}
.modal :deep(.modal-title),
.modal :deep(h3) {
margin: 0 0 var(--space-md);
font-size: 1.25rem;
font-weight: 600;
color: var(--color-heading);
}
.modal :deep(form) {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.modal :deep(.modal-form) {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.modal :deep(.modal-form label) {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-weight: 500;
}
.modal :deep(.modal-form input),
.modal :deep(.modal-form textarea) {
padding: var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 1rem;
line-height: 1.4;
min-height: 2.5rem;
}
.modal :deep(.modal-form input:focus),
.modal :deep(.modal-form textarea:focus) {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
.modal :deep(.modal-actions) {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
margin-top: var(--space-md);
margin-bottom: var(--space-xs);
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div class="name-edit-form">
<label :for="resolvedInputId">{{ label }}
<input
:id="resolvedInputId"
ref="inputRef"
:type="inputType"
:placeholder="placeholder"
v-model="localValue"
:disabled="busy"
required
/>
</label>
<div v-if="error" class="error small">{{ error }}</div>
<div class="modal-actions">
<button
type="button"
class="btn-secondary"
@click="handleCancel"
:disabled="busy"
>
{{ cancelText }}
</button>
<button
type="submit"
class="btn-primary"
:disabled="busy"
>
{{ submitText }}
</button>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue'
const props = defineProps({
modelValue: { type: String, default: '' },
label: { type: String, default: 'Name' },
placeholder: { type: String, default: '' },
submitText: { type: String, default: 'Save' },
cancelText: { type: String, default: 'Cancel' },
busy: { type: Boolean, default: false },
error: { type: String, default: '' },
autoFocus: { type: Boolean, default: true },
autoSelect: { type: Boolean, default: true },
inputId: { type: String, default: null },
inputType: { type: String, default: 'text' }
})
const emit = defineEmits(['update:modelValue', 'cancel'])
const inputRef = ref(null)
const generatedId = `name-edit-${Math.random().toString(36).slice(2, 10)}`
const localValue = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const resolvedInputId = computed(() => props.inputId || generatedId)
onMounted(() => {
if (!props.autoFocus) return
nextTick(() => {
if (props.autoSelect) {
inputRef.value?.select()
} else {
inputRef.value?.focus()
}
})
})
function handleCancel() {
emit('cancel')
}
</script>
<style scoped>
.name-edit-form {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.error {
color: var(--color-danger-text);
}
.small {
font-size: 0.9rem;
}
</style>

View File

@@ -1,43 +0,0 @@
<template>
<div class="container">
<div class="view active">
<h1>🚫 Forbidden</h1>
<div v-if="authStore.userInfo?.authenticated" class="user-header">
<span class="user-emoji" aria-hidden="true">{{ userEmoji }}</span>
<span class="user-name">{{ displayName }}</span>
</div>
<p>You lack the permissions required for this page.</p>
<div class="actions">
<button class="btn-secondary" @click="back">Back</button>
<button class="btn-primary" @click="goAuth">Account</button>
<button class="btn-danger" @click="logout">Logout</button>
</div>
</div>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const userEmoji = '👤' // Placeholder / could be extended later if backend provides one
const displayName = authStore.userInfo?.user?.user_name || 'User'
function goAuth() {
location.href = '/auth/'
}
function back() {
if (history.length > 1) history.back()
else authStore.currentView = 'login'
}
async function logout() {
await authStore.logout()
}
</script>
<style scoped>
.user-header { display:flex; align-items:center; gap:.5rem; font-size:1.1rem; margin-bottom:.75rem; }
.user-emoji { font-size:1.5rem; line-height:1; }
.user-name { font-weight:600; }
.actions { margin-top:1.5rem; display:flex; gap:.5rem; flex-wrap:nowrap; }
.hint { font-size:.9rem; opacity:.85; }
</style>

View File

@@ -1,121 +1,122 @@
<template>
<div class="container">
<div class="view active">
<h1>👋 Welcome! <a v-if="isAdmin" href="/auth/admin/" class="admin-link" title="Admin Console">Admin</a></h1>
<UserBasicInfo
v-if="authStore.userInfo?.user"
:name="authStore.userInfo.user.user_name"
:visits="authStore.userInfo.user.visits || 0"
:created-at="authStore.userInfo.user.created_at"
:last-seen="authStore.userInfo.user.last_seen"
:loading="authStore.isLoading"
update-endpoint="/auth/api/user/display-name"
@saved="authStore.loadUserInfo()"
/>
<section class="view-root" data-view="profile">
<div class="view-content">
<header class="view-header">
<h1>👋 Welcome!</h1>
<Breadcrumbs :entries="breadcrumbEntries" />
<p class="view-lede">Manage your account details and passkeys.</p>
</header>
<h2>Your Passkeys</h2>
<div class="credential-list">
<div v-if="authStore.isLoading">
<p>Loading credentials...</p>
<section class="section-block">
<UserBasicInfo
v-if="authStore.userInfo?.user"
:name="authStore.userInfo.user.user_name"
:visits="authStore.userInfo.user.visits || 0"
:created-at="authStore.userInfo.user.created_at"
: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 v-else-if="authStore.userInfo?.credentials?.length === 0">
<p>No passkeys found.</p>
</div>
<div v-else>
<div
v-for="credential in authStore.userInfo?.credentials || []"
:key="credential.credential_uuid"
:class="['credential-item', { 'current-session': credential.is_current_session }]"
>
<div class="credential-header">
<div class="credential-icon">
<img
v-if="getCredentialAuthIcon(credential)"
:src="getCredentialAuthIcon(credential)"
:alt="getCredentialAuthName(credential)"
class="auth-icon"
width="32"
height="32"
>
<span v-else class="auth-emoji">🔑</span>
</div>
<div class="credential-info">
<h4>{{ getCredentialAuthName(credential) }}</h4>
</div>
<div class="credential-dates">
<span class="date-label">Created:</span>
<span class="date-value">{{ formatDate(credential.created_at) }}</span>
<span class="date-label">Last used:</span>
<span class="date-value">{{ formatDate(credential.last_used) }}</span>
</div>
<div class="credential-actions">
<button
@click="deleteCredential(credential.credential_uuid)"
class="btn-delete-credential"
:disabled="credential.is_current_session"
:title="credential.is_current_session ? 'Cannot delete current session credential' : ''"
>
🗑
</button>
</div>
</div>
<div class="section-body">
<CredentialList
: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>
</div>
</section>
<div class="button-group" style="display: flex; gap: 10px;">
<button @click="addNewCredential" class="btn-primary">
Add New Passkey
</button>
<button @click="authStore.currentView = 'device-link'" class="btn-primary">
Add Another Device
</button>
</div>
<button @click="logout" class="btn-danger" style="width: 100%;">
Logout
</button>
<SessionList
:sessions="sessions"
:terminating-sessions="terminatingSessions"
@terminate="terminateSession"
section-description="Review where you're signed in and end any sessions you no longer recognize."
/>
<Modal v-if="showNameDialog" @close="showNameDialog = false">
<h3>Edit Display Name</h3>
<form @submit.prevent="saveName" class="modal-form">
<NameEditForm
label="Display Name"
v-model="newName"
:busy="saving"
@cancel="showNameDialog = false"
/>
</form>
</Modal>
<section class="section-block">
<div class="button-row logout-row" :class="{ single: !hasMultipleSessions }">
<button
type="button"
class="btn-secondary"
@click="history.back()"
>
Back
</button>
<button v-if="!hasMultipleSessions" @click="logoutEverywhere" class="btn-danger logout-button" :disabled="authStore.isLoading">Logout</button>
<template v-else>
<button @click="logout" class="btn-danger logout-button" :disabled="authStore.isLoading">Logout</button>
<button @click="logoutEverywhere" class="btn-danger logout-button" :disabled="authStore.isLoading">All</button>
</template>
</div>
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
<p class="logout-note" v-else><strong>Logout</strong> this session on {{ currentSessionHost }}, or <strong>All</strong> sessions across all sites and devices for {{ rpName }}. You'll need to log in again with your passkey afterwards.</p>
</section>
<RegistrationLinkModal
v-if="showRegLink"
:endpoint="'/auth/api/user/create-link'"
:auto-copy="false"
:prefix-copy-with-user-name="false"
@close="showRegLink = false"
@copied="showRegLink = false; authStore.showMessage('Link copied to clipboard!', 'success', 2500)"
/>
</div>
</div>
</section>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { formatDate } from '@/utils/helpers'
import passkey from '@/utils/passkey'
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import Breadcrumbs from '@/components/Breadcrumbs.vue'
import CredentialList from '@/components/CredentialList.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
import SessionList from '@/components/SessionList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings'
import passkey from '@/utils/passkey'
const authStore = useAuthStore()
const updateInterval = ref(null)
const showNameDialog = ref(false)
const showRegLink = ref(false)
const newName = ref('')
const saving = ref(false)
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
onMounted(() => {
updateInterval.value = setInterval(() => {
// Trigger Vue reactivity to update formatDate fields
if (authStore.userInfo) {
authStore.userInfo = { ...authStore.userInfo }
}
}, 60000) // Update every minute
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
})
onUnmounted(() => {
if (updateInterval.value) {
clearInterval(updateInterval.value)
}
})
const getCredentialAuthName = (credential) => {
const authInfo = authStore.userInfo?.aaguid_info?.[credential.aaguid]
return authInfo ? authInfo.name : 'Unknown Authenticator'
}
const getCredentialAuthIcon = (credential) => {
const authInfo = authStore.userInfo?.aaguid_info?.[credential.aaguid]
if (!authInfo) return null
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
return authInfo[iconKey] || null
}
onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) })
const addNewCredential = async () => {
try {
@@ -127,42 +128,72 @@ const addNewCredential = async () => {
} catch (error) {
console.error('Failed to add new passkey:', error)
authStore.showMessage(error.message, 'error')
} finally {
authStore.isLoading = false
}
} finally { authStore.isLoading = false }
}
const deleteCredential = async (credentialId) => {
const handleDelete = async (credential) => {
const credentialId = credential?.credential_uuid
if (!credentialId) return
if (!confirm('Are you sure you want to delete this passkey?')) return
try {
await authStore.deleteCredential(credentialId)
authStore.showMessage('Passkey deleted successfully!', 'success', 3000)
} catch (error) {
authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error')
} catch (error) { authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error') }
}
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
const sessions = computed(() => authStore.userInfo?.sessions || [])
const currentSessionHost = computed(() => {
const currentSession = sessions.value.find(session => session.is_current)
return currentSession?.host || 'this host'
})
const terminatingSessions = ref({})
const terminateSession = async (session) => {
const sessionId = session?.id
if (!sessionId) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
try { await authStore.terminateSession(sessionId) }
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
finally {
const next = { ...terminatingSessions.value }
delete next[sessionId]
terminatingSessions.value = next
}
}
const logout = async () => {
await authStore.logout()
}
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
const logout = async () => { await authStore.logout() }
const openNameDialog = () => { newName.value = authStore.userInfo?.user?.user_name || ''; showNameDialog.value = true }
const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin))
const hasMultipleSessions = computed(() => sessions.value.length > 1)
const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
const saveName = async () => {
const name = newName.value.trim()
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
try {
saving.value = true
const res = await fetch('/auth/api/user/display-name', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name }) })
const data = await res.json()
if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed')
showNameDialog.value = false
await authStore.loadUserInfo()
authStore.showMessage('Name updated successfully!', 'success', 3000)
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
finally { saving.value = false }
}
</script>
<style scoped>
/* Removed inline user info styles; now provided by UserBasicInfo component */
.admin-link {
font-size: 0.6em;
margin-left: 0.75rem;
text-decoration: none;
background: var(--color-background-soft, #eee);
padding: 0.2em 0.6em;
border-radius: 999px;
border: 1px solid var(--color-border, #ccc);
vertical-align: middle;
line-height: 1.2;
}
.admin-link:hover {
background: var(--color-background-mute, #ddd);
}
.view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; }
.section-header { display: flex; flex-direction: column; gap: 0.4rem; }
.section-description { margin: 0; color: var(--color-text-muted); }
.empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; }
.logout-button { align-self: flex-start; }
.logout-row { gap: 1rem; }
.logout-row.single { justify-content: flex-start; }
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
@media (max-width: 720px) { .logout-button { width: 100%; } }
</style>

View File

@@ -1,8 +1,10 @@
<template>
<div class="dialog-overlay" @keydown.esc.prevent="$emit('close')">
<div v-if="!inline" class="dialog-overlay" @keydown.esc.prevent="$emit('close')">
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<h2 id="regTitle" style="margin:0; font-size:1.25rem;">📱 Device Registration Link</h2>
<div class="reg-header-row">
<h2 id="regTitle" class="reg-title">
📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span>
</h2>
<button class="icon-btn" @click="$emit('close')" aria-label="Close"></button>
</div>
<div class="device-link-section">
@@ -14,28 +16,62 @@
<div v-else>
<em>Generating link...</em>
</div>
<p>
<strong>Scan and visit the URL on another device.</strong><br>
<small> Expires in 24 hours and one-time use.</small>
<p class="reg-help">
<span v-if="userName">The user should open this link on the device where they want to register.</span>
<span v-else>Open or scan this link on the device you wish to register to your account.</span>
<br><small>{{ expirationMessage }}</small>
</p>
<div v-if="expires" style="font-size:12px; margin-top:6px;">Expires: {{ new Date(expires).toLocaleString() }}</div>
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:.5rem; margin-top:10px;">
<div class="reg-actions">
<button class="btn-secondary" @click="$emit('close')">Close</button>
<button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button>
</div>
</div>
</div>
<div v-else class="registration-inline-wrapper">
<div class="registration-inline-block section-block">
<div class="section-header">
<h2 class="inline-heading">📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span></h2>
</div>
<div class="section-body">
<div class="device-link-section">
<div class="qr-container">
<a v-if="url" :href="url" @click.prevent="copy" class="qr-link">
<canvas ref="qrCanvas" class="qr-code"></canvas>
<p>{{ displayUrl }}</p>
</a>
<div v-else>
<em>Generating link...</em>
</div>
<p class="reg-help">
<span v-if="userName">The user should open this link on the device where they want to register.</span>
<span v-else>Open this link on the device you wish to connect with.</span>
<br><small>{{ expirationMessage }}</small>
</p>
</div>
</div>
<div class="button-row" style="margin-top:1rem;">
<button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button>
<button v-if="showCloseInInline" class="btn-secondary" @click="$emit('close')">Close</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, computed, nextTick } from 'vue'
import QRCode from 'qrcode/lib/browser'
import { formatDate } from '@/utils/helpers'
const props = defineProps({
endpoint: { type: String, required: true }, // POST endpoint returning {url, expires}
autoCopy: { type: Boolean, default: true }
endpoint: { type: String, required: true },
autoCopy: { type: Boolean, default: true },
userName: { type: String, default: null },
inline: { type: Boolean, default: false },
showCloseInInline: { type: Boolean, default: false },
prefixCopyWithUserName: { type: Boolean, default: false }
})
const emit = defineEmits(['close','generated','copied'])
@@ -46,6 +82,11 @@ const qrCanvas = ref(null)
const displayUrl = computed(() => url.value ? url.value.replace(/^[^:]+:\/\//,'') : '')
const expirationMessage = computed(() => {
const timeStr = formatDate(expires.value)
return `⚠️ Expires ${timeStr.startsWith('In ') ? timeStr.substring(3) : timeStr} and can only be used once.`
})
async function fetchLink() {
try {
const res = await fetch(props.endpoint, { method: 'POST' })
@@ -73,15 +114,35 @@ async function drawQR() {
async function copy() {
if (!url.value) return
try { await navigator.clipboard.writeText(url.value); emit('copied', url.value); emit('close') } catch (_) { /* ignore */ }
let text = url.value
if (props.prefixCopyWithUserName && props.userName) {
text = `${props.userName} ${text}`
}
try {
await navigator.clipboard.writeText(text)
emit('copied', text)
if (!props.inline) emit('close')
} catch (_) {
/* ignore */
}
}
onMounted(fetchLink)
watch(url, () => drawQR(), { flush: 'post' })
</script>
<style scoped>
.icon-btn { background:none; border:none; cursor:pointer; font-size:1rem; opacity:.6; }
.icon-btn:hover { opacity:1; }
/* Minimal extra styling; main look comes from global styles */
.qr-link { text-decoration:none; color:inherit; }
.reg-header-row { display:flex; justify-content:space-between; align-items:center; gap:.75rem; margin-bottom:.75rem; }
.reg-title { margin:0; font-size:1.25rem; font-weight:600; }
.device-dialog { background: var(--color-surface); padding: 1.25rem 1.25rem 1rem; border-radius: var(--radius-md); max-width:480px; width:100%; box-shadow:0 6px 28px rgba(0,0,0,.25); }
.qr-container { display:flex; flex-direction:column; align-items:center; gap:.5rem; }
.qr-code { display:block; }
.reg-help { margin-top:.5rem; margin-bottom:.75rem; font-size:.85rem; line-height:1.25rem; text-align:center; }
.reg-actions { display:flex; justify-content:flex-end; gap:.5rem; margin-top:.25rem; }
.registration-inline-block .qr-container { align-items:flex-start; }
.registration-inline-block .reg-help { text-align:left; }
</style>

View File

@@ -1,55 +0,0 @@
<template>
<div class="container">
<div class="view active">
<h1>🔑 Add New Credential</h1>
<label class="name-edit">
<span>👤 Name:</span>
<input
type="text"
v-model="user_name"
:placeholder="authStore.userInfo?.user?.user_name || 'Your name'"
:disabled="authStore.isLoading"
maxlength="64"
@keyup.enter="register"
/>
</label>
<p>Proceed to complete {{authStore.userInfo?.session_type}}:</p>
<button
class="btn-primary"
:disabled="authStore.isLoading"
@click="register"
>
{{ authStore.isLoading ? 'Registering...' : 'Register Passkey' }}
</button>
</div>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
import passkey from '@/utils/passkey'
import { ref } from 'vue'
const authStore = useAuthStore()
const user_name = ref('') // intentionally blank; original shown via placeholder
async function register() {
authStore.isLoading = true
authStore.showMessage('Starting registration...', 'info')
try {
const result = await passkey.register(authStore.resetToken, user_name.value)
console.log("Result", result)
await authStore.setSessionCookie(result.session_token)
// resetToken cleared by setSessionCookie; ensure again
authStore.resetToken = null
authStore.showMessage('Passkey registered successfully!', 'success', 2000)
await authStore.loadUserInfo()
authStore.selectView()
} catch (error) {
authStore.showMessage(`Registration failed: ${error.message}`, 'error')
} finally {
authStore.isLoading = false
}
}
</script>

View File

@@ -0,0 +1,73 @@
<template>
<section class="section-block" data-component="session-list-section">
<div class="section-header">
<h2>Active Sessions</h2>
<p class="section-description">{{ sectionDescription }}</p>
</div>
<div class="section-body">
<div :class="['session-list']">
<template v-if="Array.isArray(sessions) && sessions.length">
<div
v-for="session in sessions"
:key="session.id"
:class="['session-item', { 'is-current': session.is_current }]"
>
<div class="item-top">
<div class="item-icon">
<span class="session-emoji">🌐</span>
</div>
<h4 class="item-title">{{ sessionHostLabel(session) }}</h4>
<div class="item-actions">
<span v-if="session.is_current" class="badge badge-current">Current</span>
<span v-else-if="session.is_current_host" class="badge">This host</span>
<button
v-if="allowTerminate"
@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">Last used:</span>
<span class="date-value">{{ formatDate(session.last_renewed) }}</span>
<span class="session-meta-info">{{ session.user_agent }} {{ session.ip }}</span>
</div>
</div>
</div>
</template>
<div v-else class="empty-state"><p>{{ emptyMessage }}</p></div>
</div>
</div>
</section>
</template>
<script setup>
import { } from 'vue'
import { formatDate } from '@/utils/helpers'
const props = defineProps({
sessions: { type: Array, default: () => [] },
allowTerminate: { type: Boolean, default: true },
emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
terminatingSessions: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['terminate'])
const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId]
const sessionHostLabel = (session) => {
if (!session || !session.host) return 'Unbound host'
return session.host
}
</script>
<style>
.session-meta-info {
grid-column: span 2;
}
</style>

View File

@@ -2,21 +2,9 @@
<div v-if="userLoaded" class="user-info">
<h3 class="user-name-heading">
<span class="icon">👤</span>
<span v-if="!editingName" class="user-name-row">
<span class="user-name-row">
<span class="display-name" :title="name">{{ name }}</span>
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="startEdit" title="Edit name"></button>
</span>
<span v-else class="user-name-row editing">
<input
v-model="newName"
class="name-input"
:placeholder="name"
:disabled="busy || loading"
maxlength="64"
@keyup.enter="saveName"
/>
<button class="mini-btn" @click="saveName" :disabled="busy || loading" title="Save name">💾</button>
<button class="mini-btn" @click="cancelEdit" :disabled="busy || loading" title="Cancel"></button>
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('editName')" title="Edit name"></button>
</span>
</h3>
<div v-if="orgDisplayName || roleName" class="org-role-sub">
@@ -49,53 +37,29 @@ const props = defineProps({
roleName: { type: String, default: '' }
})
const emit = defineEmits(['saved'])
const emit = defineEmits(['saved', 'editName'])
const authStore = useAuthStore()
const editingName = ref(false)
const newName = ref('')
const busy = ref(false)
const userLoaded = computed(() => !!props.name)
function startEdit() { editingName.value = true; newName.value = '' }
function cancelEdit() { editingName.value = false }
async function saveName() {
if (!props.updateEndpoint) { editingName.value = false; return }
try {
busy.value = true
authStore.isLoading = true
const bodyName = newName.value.trim()
if (!bodyName) { cancelEdit(); return }
const res = await fetch(props.updateEndpoint, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: bodyName }) })
let data = {}
try { data = await res.json() } catch (_) {}
if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed')
editingName.value = false
authStore.showMessage('Name updated', 'success', 1500)
emit('saved')
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
finally { busy.value = false; authStore.isLoading = false }
}
watch(() => props.name, () => { if (!props.name) editingName.value = false })
</script>
<style scoped>
.user-info { display: grid; grid-template-columns: auto 1fr; gap: 10px; }
.user-info h3 { grid-column: span 2; }
.org-role-sub { grid-column: span 2; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; }
.role-line { font-size:.6rem; color:#555; line-height:1.1; }
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; }
.user-info span { text-align: left; }
.user-name-heading { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; }
.user-name-row.editing { flex: 1 1 auto; }
.icon { flex: 0 0 auto; }
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid #a9c5d6; border-radius: 6px; }
.name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid var(--color-border-strong); border-radius: 6px; background: var(--color-surface); color: var(--color-text); }
.user-name-heading .name-input { width: auto; }
.name-input:focus { outline: 2px solid #667eea55; border-color: #667eea; }
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; background: #eef5fa; border: 1px solid #b7d2e3; border-radius: 6px; cursor: pointer; transition: background 0.2s, transform 0.15s; }
.mini-btn:hover:not(:disabled) { background: #dcecf6; }
.name-input:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); }
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; background: var(--color-surface-muted); border: 1px solid var(--color-border-strong); border-radius: 6px; cursor: pointer; transition: background 0.2s, transform 0.15s, color 0.2s ease; color: var(--color-text); }
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
.mini-btn:active:not(:disabled) { transform: translateY(1px); }
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
@media (max-width: 480px) { .user-name-heading { flex-direction: column; align-items: flex-start; } .user-name-row.editing { width: 100%; } .display-name { max-width: 100%; } }

View File

@@ -0,0 +1,138 @@
<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>

11
frontend/src/host/main.js Normal file
View File

@@ -0,0 +1,11 @@
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')

View File

@@ -0,0 +1,249 @@
<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 class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;">
<header class="view-header" style="text-align: center;">
<h1>🔑 Registration</h1>
<p class="view-lede">
{{ subtitleMessage }}
</p>
</header>
<section class="section-block" v-if="initializing">
<div class="section-body center">
<p>Loading reset details</p>
</div>
</section>
<section class="section-block" v-else-if="!canRegister">
<div class="section-body center">
<p>{{ errorMessage }}</p>
<div class="button-row center" style="justify-content: center;">
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
</div>
</div>
</section>
<section class="section-block" v-else>
<div class="section-body">
<label class="name-edit">
<span>👤 Name</span>
<input
type="text"
v-model="displayName"
:disabled="loading"
maxlength="64"
@keyup.enter="registerPasskey"
/>
</label>
<button
class="btn-primary"
:disabled="loading"
@click="registerPasskey"
>
{{ loading ? 'Registering…' : 'Register Passkey' }}
</button>
</div>
</section>
</div>
</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 token = ref('')
const settings = ref(null)
const userInfo = ref(null)
const displayName = ref('')
const errorMessage = ref('')
let statusTimer = null
const sessionDescriptor = computed(() => userInfo.value?.session_type || 'your enrollment')
const subtitleMessage = computed(() => {
if (initializing.value) return 'Preparing your secure enrollment…'
if (!canRegister.value) return 'This reset link is no longer valid.'
return `Finish up ${sessionDescriptor.value}. You may edit the name below if needed, and it will be saved to your passkey.`
})
const basePath = computed(() => uiBasePath())
const canRegister = computed(() => !!(token.value && userInfo.value))
function showMessage(message, type = 'info', duration = 3000) {
status.show = true
status.message = message
status.type = type
if (statusTimer) clearTimeout(statusTimer)
if (duration > 0) {
statusTimer = setTimeout(() => {
status.show = false
}, duration)
}
}
async function fetchSettings() {
try {
const data = await getSettings()
settings.value = data
if (data?.rp_name) document.title = `${data.rp_name} · Passkey Setup`
} catch (error) {
console.warn('Unable to load settings', error)
}
}
async function fetchUserInfo() {
if (!token.value) return
try {
const res = await fetch(`/auth/api/user-info?reset=${encodeURIComponent(token.value)}`, {
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 || ''
} catch (error) {
console.error('Failed to load user info', error)
const message = 'We could not load your reset details. Try refreshing the page.'
errorMessage.value = message
showMessage(message, 'error', 0)
}
}
async function registerPasskey() {
if (!canRegister.value || loading.value) return
loading.value = true
showMessage('Starting passkey registration…', 'info')
let result
try {
const nameValue = displayName.value.trim() || null
result = await passkey.register(token.value, nameValue)
} catch (error) {
loading.value = false
const message = error?.message || 'Passkey registration cancelled'
const cancelled = message === 'Passkey registration cancelled'
showMessage(cancelled ? message : `Registration failed: ${message}`, cancelled ? 'info' : 'error', 4000)
return
}
try {
await setSessionCookie(result.session_token)
} catch (error) {
loading.value = false
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
return
}
showMessage('Passkey registered successfully!', 'success', 2000)
setTimeout(() => {
loading.value = false
redirectHome()
}, 800)
}
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) {
const detail = payload?.detail || 'Session could not be established.'
throw new Error(detail)
}
return payload
}
function redirectHome() {
const target = uiBasePath.value || '/auth/'
if (window.location.pathname !== target) {
history.replaceState(null, '', target)
}
window.location.reload()
}
function goHome() {
redirectHome()
}
function extractTokenFromPath() {
const segments = window.location.pathname.split('/').filter(Boolean)
if (!segments.length) return ''
const candidate = segments[segments.length - 1]
const prefix = segments.slice(0, -1)
if (prefix.length > 1) return ''
if (prefix.length === 1 && prefix[0] !== 'auth') return ''
if (!candidate.includes('.')) return ''
return candidate
}
async function safeParseJson(response) {
try {
return await response.json()
} catch (error) {
return null
}
}
onMounted(async () => {
token.value = extractTokenFromPath()
await fetchSettings()
if (!token.value) {
const message = 'Reset link is missing or malformed.'
errorMessage.value = message
showMessage(message, 'error', 0)
initializing.value = false
return
}
await fetchUserInfo()
initializing.value = false
})
</script>
<style scoped>
.center {
text-align: center;
}
.button-row.center {
display: flex;
justify-content: center;
}
.section-body {
gap: 1.25rem;
}
.name-edit span {
color: var(--color-text-muted);
font-size: 0.9rem;
}
</style>

View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import ResetApp from './ResetApp.vue'
import '@/assets/style.css'
createApp(ResetApp).mount('#app')

View File

@@ -0,0 +1,176 @@
<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>

View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import RestrictedApp from './RestrictedApp.vue'
import '@/assets/style.css'
createApp(RestrictedApp).mount('#app')

View File

@@ -1,14 +1,15 @@
import { defineStore } from 'pinia'
import { register, authenticate } from '@/utils/passkey'
import { getSettings } from '@/utils/settings'
export const useAuthStore = defineStore('auth', {
state: () => ({
// Auth State
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated}
settings: null, // Server provided settings (/auth/settings)
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
isLoading: false,
resetToken: null, // transient reset token
restrictedMode: false, // Anywhere other than /auth/: restrict to login or permission denied
// Settings
settings: null,
// UI State
currentView: 'login',
@@ -18,7 +19,12 @@ export const useAuthStore = defineStore('auth', {
show: false
},
}),
getters: {
},
actions: {
setLoading(flag) {
this.isLoading = !!flag
},
showMessage(message, type = 'info', duration = 3000) {
this.status = {
message,
@@ -32,7 +38,7 @@ export const useAuthStore = defineStore('auth', {
}
},
async setSessionCookie(sessionToken) {
const response = await fetch('/auth/api/set-session', {
const response = await fetch('/auth/api/set-session', {
method: 'POST',
headers: {'Authorization': `Bearer ${sessionToken}`},
})
@@ -40,9 +46,6 @@ export const useAuthStore = defineStore('auth', {
if (result.detail) {
throw new Error(result.detail)
}
// On successful session establishment, discard any reset token to avoid
// sending stale Authorization headers on subsequent API calls.
this.resetToken = null
return result
},
async register() {
@@ -51,6 +54,7 @@ export const useAuthStore = defineStore('auth', {
const result = await register()
await this.setSessionCookie(result.session_token)
await this.loadUserInfo()
this.selectView()
return result
} finally {
this.isLoading = false
@@ -63,6 +67,7 @@ export const useAuthStore = defineStore('auth', {
await this.setSessionCookie(result.session_token)
await this.loadUserInfo()
this.selectView()
return result
} finally {
@@ -70,25 +75,14 @@ export const useAuthStore = defineStore('auth', {
}
},
selectView() {
if (this.restrictedMode) {
// In restricted mode only allow login or show permission denied if already authenticated
if (!this.userInfo) this.currentView = 'login'
else if (this.userInfo.authenticated) this.currentView = 'permission-denied'
else this.currentView = 'login' // do not expose reset/registration flows outside /auth/
return
}
if (!this.userInfo) this.currentView = 'login'
else if (this.userInfo.authenticated) this.currentView = 'profile'
else this.currentView = 'reset'
else this.currentView = 'profile'
},
setRestrictedMode(flag) {
this.restrictedMode = !!flag
async loadSettings() {
this.settings = await getSettings()
},
async loadUserInfo() {
const headers = {}
// Reset tokens are only passed via query param now, not Authorization header
const url = this.resetToken ? `/auth/api/user-info?reset=${encodeURIComponent(this.resetToken)}` : '/auth/api/user-info'
const response = await fetch(url, { method: 'POST', headers })
const response = await fetch('/auth/api/user-info', { method: 'POST' })
let result = null
try {
result = await response.json()
@@ -107,34 +101,77 @@ export const useAuthStore = defineStore('auth', {
this.userInfo = result
console.log('User info loaded:', result)
},
async loadSettings() {
try {
const res = await fetch('/auth/api/settings')
if (!res.ok) return
const data = await res.json()
this.settings = data
if (data?.rp_name) {
document.title = data.rp_name
}
} catch (_) {
// ignore
}
},
async deleteCredential(uuid) {
const response = await fetch(`/auth/api/credential/${uuid}`, {method: 'Delete'})
const response = await fetch(`/auth/api/user/credential/${uuid}`, {method: 'Delete'})
const result = await response.json()
if (result.detail) throw new Error(`Server: ${result.detail}`)
await this.loadUserInfo()
},
async terminateSession(sessionId) {
try {
const res = await fetch(`/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) {
sessionStorage.clear()
location.reload()
return
}
await this.loadUserInfo()
this.showMessage('Session terminated', 'success', 2500)
} catch (error) {
console.error('Terminate session error:', error)
throw error
}
},
async logout() {
try {
await fetch('/auth/api/logout', {method: 'POST'})
const res = await fetch('/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()
location.reload()
} catch (error) {
console.error('Logout error:', error)
this.showMessage(error.message, 'error')
}
},
async logoutEverywhere() {
try {
const res = await fetch('/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()
location.reload()
} catch (error) {
console.error('Logout-all error:', error)
this.showMessage(error.message, 'error')
}
},
}
})

View File

@@ -5,16 +5,18 @@ export function formatDate(dateString) {
const date = new Date(dateString)
const now = new Date()
const diffMs = now - date
const diffMinutes = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
const diffMs = date - now // Changed to date - now for future/past
const isFuture = diffMs > 0
const absDiffMs = Math.abs(diffMs)
const diffMinutes = Math.round(absDiffMs / (1000 * 60))
const diffHours = Math.round(absDiffMs / (1000 * 60 * 60))
const diffDays = Math.round(absDiffMs / (1000 * 60 * 60 * 24))
if (diffMs < 0 || diffDays > 7) return date.toLocaleDateString()
if (diffMinutes === 0) return 'Just now'
if (diffMinutes < 60) return diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago`
if (diffHours < 24) return diffHours === 1 ? 'an hour ago' : `${diffHours} hours ago`
return diffDays === 1 ? 'a day ago' : `${diffDays} days ago`
if (absDiffMs < 1000 * 60) return 'Now'
if (diffMinutes <= 60) return isFuture ? `In ${diffMinutes} minute${diffMinutes === 1 ? '' : 's'}` : diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago`
if (diffHours <= 24) return isFuture ? `In ${diffHours} hour${diffHours === 1 ? '' : 's'}` : diffHours === 1 ? 'an hour ago' : `${diffHours} hours ago`
if (diffDays <= 14) return isFuture ? `In ${diffDays} day${diffDays === 1 ? '' : 's'}` : diffDays === 1 ? 'a day ago' : `${diffDays} days ago`
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
}
export function getCookie(name) {

View File

@@ -1,13 +1,21 @@
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket'
import { getSettings } from '@/utils/settings'
// Generic path normalizer: if an auth_host is configured and differs from current
// host, return absolute URL (scheme derived by aWebSocket). Otherwise, keep as-is.
async function makeUrl(path) {
const s = await getSettings()
const h = s?.auth_host
return h && location.host !== h ? `//${h}${path}` : path
}
export async function register(resetToken = null, displayName = null) {
let params = []
if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`)
if (displayName) params.push(`name=${encodeURIComponent(displayName)}`)
const qs = params.length ? `?${params.join('&')}` : ''
const url = `/auth/ws/register${qs}`
const ws = await aWebSocket(url)
const ws = await aWebSocket(await makeUrl(`/auth/ws/register${qs}`))
try {
const optionsJSON = await ws.receive_json()
const registrationResponse = await startRegistration({ optionsJSON })
@@ -23,7 +31,7 @@ export async function register(resetToken = null, displayName = null) {
}
export async function authenticate() {
const ws = await aWebSocket('/auth/ws/authenticate')
const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate'))
try {
const optionsJSON = await ws.receive_json()
const authResponse = await startAuthentication({ optionsJSON })

View File

@@ -0,0 +1,29 @@
let _settingsPromise = null
let _settings = null
export function getSettingsCached() { return _settings }
export async function getSettings() {
if (_settings) return _settings
if (_settingsPromise) return _settingsPromise
_settingsPromise = fetch('/auth/api/settings')
.then(r => (r.ok ? r.json() : {}))
.then(obj => { _settings = obj || {}; return _settings })
.catch(() => { _settings = {}; return _settings })
return _settingsPromise
}
export function uiBasePath() {
const base = _settings?.ui_base_path || '/auth/'
if (base === '/') return '/'
return base.endsWith('/') ? base : base + '/'
}
export function adminUiPath() { return uiBasePath() === '/' ? '/admin/' : uiBasePath() + 'admin/' }
export function makeUiHref(suffix = '') {
const trimmed = suffix.startsWith('/') ? suffix.slice(1) : suffix
if (!trimmed) return uiBasePath()
if (uiBasePath() === '/') return '/' + trimmed
return uiBasePath() + trimmed
}

View File

@@ -33,8 +33,14 @@ export default defineConfig(({ command, mode }) => ({
// 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.
}
}
@@ -47,7 +53,10 @@ export default defineConfig(({ command, mode }) => ({
rollupOptions: {
input: {
index: resolve(__dirname, 'index.html'),
admin: resolve(__dirname, 'admin/index.html')
admin: resolve(__dirname, 'admin/index.html'),
reset: resolve(__dirname, 'reset/index.html'),
restricted: resolve(__dirname, 'restricted/index.html'),
host: resolve(__dirname, 'host/index.html')
},
output: {}
}

View File

@@ -8,61 +8,115 @@ independent of any web framework:
- Credential management
"""
from datetime import datetime, timedelta
from datetime import datetime, timezone
from uuid import UUID
from .db import Session
from .globals import db
from .config import SESSION_LIFETIME
from .db import ResetToken, Session
from .globals import db, passkey
from .util import hostutil
from .util.tokens import create_token, reset_key, session_key
EXPIRES = timedelta(hours=24)
EXPIRES = SESSION_LIFETIME
def expires() -> datetime:
return datetime.now() + EXPIRES
return datetime.now(timezone.utc) + EXPIRES
async def create_session(user_uuid: UUID, credential_uuid: UUID, info: dict) -> str:
def reset_expires() -> datetime:
from .config import RESET_LIFETIME
return datetime.now(timezone.utc) + RESET_LIFETIME
def session_expiry(session: Session) -> datetime:
"""Calculate the expiration timestamp for a session (UTC aware)."""
# After migration all renewed timestamps are timezone-aware UTC
return session.renewed + EXPIRES
async def create_session(
user_uuid: UUID,
credential_uuid: UUID,
*,
host: str,
ip: str,
user_agent: str,
) -> str:
"""Create a new session and return a session token."""
normalized_host = hostutil.normalize_host(host)
if not normalized_host:
raise ValueError("Host required for session creation")
hostname = normalized_host.split(":")[0] # Domain names only, IPs aren't supported
rp_id = passkey.instance.rp_id
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
token = create_token()
now = datetime.now(timezone.utc)
await db.instance.create_session(
user_uuid=user_uuid,
credential_uuid=credential_uuid,
key=session_key(token),
expires=datetime.now() + EXPIRES,
info=info,
host=normalized_host,
ip=ip,
user_agent=user_agent,
renewed=now,
)
return token
async def get_reset(token: str) -> Session:
async def get_reset(token: str) -> ResetToken:
"""Validate a credential reset token. Returns None if the token is not well formed (i.e. it is another type of token)."""
session = await db.instance.get_session(reset_key(token))
if not session:
record = await db.instance.get_reset_token(reset_key(token))
if not record:
raise ValueError("Invalid or expired session token")
return session
if record.expiry < datetime.now(timezone.utc):
await db.instance.delete_reset_token(record.key)
raise ValueError("Invalid or expired session token")
return record
async def get_session(token: str) -> Session:
async def get_session(token: str, host: str | None = None) -> Session:
"""Validate a session token and return session data if valid."""
session = await db.instance.get_session(session_key(token))
if not session:
raise ValueError("Invalid or expired session token")
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.
await db.instance.set_session_host(session.key, normalized_host)
session.host = normalized_host
elif current == normalized_host:
pass # exact match ok
else:
raise ValueError("Invalid or expired session token")
return session
async def refresh_session_token(token: str):
async def refresh_session_token(token: str, *, ip: str, user_agent: str):
"""Refresh a session extending its expiry."""
# Get the current session
s = await db.instance.update_session(
session_key(token), datetime.now() + EXPIRES, {}
session_record = await db.instance.get_session(session_key(token))
if not session_record:
raise ValueError("Session not found or expired")
updated = await db.instance.update_session(
session_key(token),
ip=ip,
user_agent=user_agent,
renewed=datetime.now(timezone.utc),
)
if not s:
if not updated:
raise ValueError("Session not found or expired")
async def delete_credential(credential_uuid: UUID, auth: str):
async def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
"""Delete a specific credential for the current user."""
s = await get_session(auth)
s = await get_session(auth, host=host)
await db.instance.delete_credential(credential_uuid, s.user_uuid)

View File

@@ -8,13 +8,13 @@ generating a reset link for initial admin setup.
import asyncio
import logging
from datetime import datetime
from datetime import datetime, timezone
import uuid7
from . import authsession, globals
from .db import Org, Permission, Role, User
from .util import passphrase, tokens
from .util import hostutil, passphrase, tokens
def _init_logger() -> logging.Logger:
@@ -41,13 +41,14 @@ ADMIN_RESET_MESSAGE = """\
async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> str:
"""Create an admin reset link and log it with the provided message."""
token = passphrase.generate()
await globals.db.instance.create_session(
expiry = authsession.reset_expires()
await globals.db.instance.create_reset_token(
user_uuid=user_uuid,
key=tokens.reset_key(token),
expires=authsession.expires(),
info={"type": session_type},
expiry=expiry,
token_type=session_type,
)
reset_link = f"{globals.passkey.instance.origin}/auth/{token}"
reset_link = hostutil.reset_link_url(token)
logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
return reset_link
@@ -90,7 +91,7 @@ async def bootstrap_system(
uuid=uuid7.create(),
display_name=user_name or "Admin",
role_uuid=role.uuid,
created_at=datetime.now(),
created_at=datetime.now(timezone.utc),
visits=0,
)
await globals.db.instance.create_user(user)

7
passkey/config.py Normal file
View File

@@ -0,0 +1,7 @@
from datetime import timedelta
# Shared configuration constants for session management.
SESSION_LIFETIME = timedelta(hours=24)
# Lifetime for reset links created by admins
RESET_LIFETIME = timedelta(days=14)

View File

@@ -63,9 +63,27 @@ class Credential:
class Session:
key: bytes
user_uuid: UUID
expires: datetime
info: dict
credential_uuid: UUID | None = None
credential_uuid: UUID
host: str
ip: str
user_agent: str
renewed: datetime
def metadata(self) -> dict:
"""Return session metadata for backwards compatibility."""
return {
"ip": self.ip,
"user_agent": self.user_agent,
"renewed": self.renewed.isoformat(),
}
@dataclass
class ResetToken:
key: bytes
user_uuid: UUID
expiry: datetime
token_type: str
@dataclass
@@ -146,9 +164,11 @@ class DatabaseInterface(ABC):
self,
user_uuid: UUID,
key: bytes,
expires: datetime,
info: dict,
credential_uuid: UUID | None = None,
credential_uuid: UUID,
host: str,
ip: str,
user_agent: str,
renewed: datetime,
) -> None:
"""Create a new session."""
@@ -162,14 +182,50 @@ class DatabaseInterface(ABC):
@abstractmethod
async def update_session(
self, key: bytes, expires: datetime, info: dict
self,
key: bytes,
*,
ip: str,
user_agent: str,
renewed: datetime,
) -> Session | None:
"""Update session expiry and info."""
"""Update session metadata and touch renewed timestamp."""
@abstractmethod
async def set_session_host(self, key: bytes, host: str) -> None:
"""Bind a session to a specific host if not already set."""
@abstractmethod
async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]:
"""Return all sessions for a user (including other hosts)."""
@abstractmethod
async def cleanup(self) -> None:
"""Called periodically to clean up expired records."""
@abstractmethod
async def delete_sessions_for_user(self, user_uuid: UUID) -> None:
"""Delete all sessions belonging to the provided user."""
# Reset token operations
@abstractmethod
async def create_reset_token(
self,
user_uuid: UUID,
key: bytes,
expiry: datetime,
token_type: str,
) -> None:
"""Create a reset token for a user."""
@abstractmethod
async def get_reset_token(self, key: bytes) -> ResetToken | None:
"""Retrieve a reset token by key."""
@abstractmethod
async def delete_reset_token(self, key: bytes) -> None:
"""Delete a reset token by key."""
# Organization operations
@abstractmethod
async def create_organization(self, org: Org) -> None:
@@ -315,36 +371,41 @@ class DatabaseInterface(ABC):
"""Create a new user and their first credential in a transaction."""
@abstractmethod
async def get_session_context(self, session_key: bytes) -> SessionContext | None:
async def get_session_context(
self, session_key: bytes, host: str | None = None
) -> SessionContext | None:
"""Get complete session context including user, organization, role, and permissions."""
# Combined atomic operations
@abstractmethod
async def create_credential_session(
self,
user_uuid: UUID,
credential: Credential,
reset_key: bytes | None,
session_key: bytes,
session_expires: datetime,
session_info: dict,
display_name: str | None = None,
) -> None:
"""Atomically add a credential and create a session.
# Combined atomic operations
@abstractmethod
async def create_credential_session(
self,
user_uuid: UUID,
credential: Credential,
reset_key: bytes | None,
session_key: bytes,
*,
display_name: str | None = None,
host: str | None = None,
ip: str | None = None,
user_agent: str | None = None,
) -> None:
"""Atomically add a credential and create a session.
Steps (single transaction):
1. Insert credential
2. Optionally delete old session (e.g. reset token) if provided
3. Optionally update user's display name
4. Insert new session referencing the credential
5. Update user's last_seen and increment visits (treat as a login)
"""
Steps (single transaction):
1. Insert credential
2. Optionally delete old reset token if provided
3. Optionally update user's display name
4. Insert new session referencing the credential
5. Update user's last_seen and increment visits (treat as a login)
"""
__all__ = [
"User",
"Credential",
"Session",
"ResetToken",
"SessionContext",
"Org",
"Role",

View File

@@ -6,7 +6,7 @@ for managing users and credentials in a WebAuthn authentication system.
"""
from contextlib import asynccontextmanager
from datetime import datetime
from datetime import datetime, timezone
from uuid import UUID
from sqlalchemy import (
@@ -17,19 +17,23 @@ from sqlalchemy import (
String,
delete,
event,
insert,
select,
text,
update,
)
from sqlalchemy.dialects.sqlite import BLOB, JSON
from sqlalchemy.dialects.sqlite import BLOB
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from ..config import SESSION_LIFETIME
from ..globals import db
from . import (
Credential,
DatabaseInterface,
Org,
Permission,
ResetToken,
Role,
Session,
SessionContext,
@@ -39,6 +43,14 @@ from . import (
DB_PATH = "sqlite+aiosqlite:///passkey-auth.sqlite"
def _normalize_dt(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
async def init(*args, **kwargs):
db.instance = DB()
await db.instance.init_db()
@@ -97,8 +109,12 @@ class UserModel(Base):
role_uuid: Mapped[bytes] = mapped_column(
LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
last_seen: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
def as_dataclass(self) -> User:
@@ -106,8 +122,8 @@ class UserModel(Base):
uuid=UUID(bytes=self.uuid),
display_name=self.display_name,
role_uuid=UUID(bytes=self.role_uuid),
created_at=self.created_at,
last_seen=self.last_seen,
created_at=_normalize_dt(self.created_at) or self.created_at,
last_seen=_normalize_dt(self.last_seen) or self.last_seen,
visits=self.visits,
)
@@ -117,7 +133,7 @@ class UserModel(Base):
uuid=user.uuid.bytes,
display_name=user.display_name,
role_uuid=user.role_uuid.bytes,
created_at=user.created_at or datetime.now(),
created_at=user.created_at or datetime.now(timezone.utc),
last_seen=user.last_seen,
visits=user.visits,
)
@@ -136,9 +152,29 @@ class CredentialModel(Base):
aaguid: Mapped[bytes] = mapped_column(LargeBinary(16), nullable=False)
public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False)
sign_count: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_verified: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
# Columns declared timezone-aware going forward; legacy rows may still be naive in storage
last_used: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_verified: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
def as_dataclass(self): # type: ignore[override]
return Credential(
uuid=UUID(bytes=self.uuid),
credential_id=self.credential_id,
user_uuid=UUID(bytes=self.user_uuid),
aaguid=UUID(bytes=self.aaguid),
public_key=self.public_key,
sign_count=self.sign_count,
created_at=_normalize_dt(self.created_at) or self.created_at,
last_used=_normalize_dt(self.last_used) or self.last_used,
last_verified=_normalize_dt(self.last_verified) or self.last_verified,
)
class SessionModel(Base):
@@ -146,23 +182,31 @@ class SessionModel(Base):
key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
user_uuid: Mapped[bytes] = mapped_column(
LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE")
LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False
)
credential_uuid: Mapped[bytes | None] = mapped_column(
LargeBinary(16), ForeignKey("credentials.uuid", ondelete="CASCADE")
credential_uuid: Mapped[bytes] = mapped_column(
LargeBinary(16),
ForeignKey("credentials.uuid", ondelete="CASCADE"),
nullable=False,
)
host: Mapped[str] = mapped_column(String, nullable=False)
ip: Mapped[str] = mapped_column(String(64), nullable=False)
user_agent: Mapped[str] = mapped_column(String(512), nullable=False)
renewed: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
)
expires: Mapped[datetime] = mapped_column(DateTime, nullable=False)
info: Mapped[dict] = mapped_column(JSON, default=dict)
def as_dataclass(self):
return Session(
key=self.key,
user_uuid=UUID(bytes=self.user_uuid),
credential_uuid=(
UUID(bytes=self.credential_uuid) if self.credential_uuid else None
),
expires=self.expires,
info=self.info,
credential_uuid=UUID(bytes=self.credential_uuid),
host=self.host,
ip=self.ip,
user_agent=self.user_agent,
renewed=_normalize_dt(self.renewed) or self.renewed,
)
@staticmethod
@@ -170,9 +214,30 @@ class SessionModel(Base):
return SessionModel(
key=session.key,
user_uuid=session.user_uuid.bytes,
credential_uuid=session.credential_uuid and session.credential_uuid.bytes,
expires=session.expires,
info=session.info,
credential_uuid=session.credential_uuid.bytes,
host=session.host,
ip=session.ip,
user_agent=session.user_agent,
renewed=session.renewed,
)
class ResetTokenModel(Base):
__tablename__ = "reset_tokens"
key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
user_uuid: Mapped[bytes] = mapped_column(
LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False
)
token_type: Mapped[str] = mapped_column(String, nullable=False)
expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
def as_dataclass(self) -> ResetToken:
return ResetToken(
key=self.key,
user_uuid=UUID(bytes=self.user_uuid),
token_type=self.token_type,
expiry=_normalize_dt(self.expiry) or self.expiry,
)
@@ -256,6 +321,58 @@ class DB(DatabaseInterface):
"""Initialize database tables."""
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
result = await conn.execute(text("PRAGMA table_info('sessions')"))
columns = {row[1] for row in result}
expected = {
"key",
"user_uuid",
"credential_uuid",
"host",
"ip",
"user_agent",
"renewed",
}
needs_recreate = False
if columns and columns != expected:
await conn.execute(text("DROP TABLE sessions"))
needs_recreate = True
result = await conn.execute(text("PRAGMA table_info('reset_tokens')"))
if not list(result):
needs_recreate = True
if needs_recreate:
await conn.run_sync(Base.metadata.create_all)
# Run one-time migration to add UTC tzinfo to any naive datetimes
await self._migrate_naive_datetimes()
async def _migrate_naive_datetimes(self) -> None:
"""Attach UTC tzinfo to any legacy naive datetime rows.
SQLite stores datetimes as text; older rows may have been inserted naive.
We treat naive timestamps as already UTC and rewrite them in ISO8601 with Z.
"""
# Helper SQL fragment for detecting naive (no timezone offset) for ISO strings
# We only update rows whose textual representation lacks a 'Z' or '+' sign.
async with self.session() as session:
# Users
for model, fields in [
(UserModel, ["created_at", "last_seen"]),
(CredentialModel, ["created_at", "last_used", "last_verified"]),
(SessionModel, ["renewed"]),
(ResetTokenModel, ["expiry"]),
]:
stmt = select(model)
result = await session.execute(stmt)
rows = result.scalars().all()
dirty = False
for row in rows:
for fname in fields:
value = getattr(row, fname, None)
if isinstance(value, datetime) and value.tzinfo is None:
setattr(row, fname, value.replace(tzinfo=timezone.utc))
dirty = True
if dirty:
# SQLAlchemy autoflush/commit in context manager will persist
pass
async def get_user_by_uuid(self, user_uuid: UUID) -> User:
async with self.session() as session:
@@ -408,9 +525,11 @@ class DB(DatabaseInterface):
credential: Credential,
reset_key: bytes | None,
session_key: bytes,
session_expires: datetime,
session_info: dict,
*,
display_name: str | None = None,
host: str | None = None,
ip: str | None = None,
user_agent: str | None = None,
) -> None:
"""Atomic credential + (optional old session delete) + (optional rename) + new session."""
async with self.session() as session:
@@ -433,10 +552,10 @@ class DB(DatabaseInterface):
last_verified=credential.last_verified,
)
)
# Delete old session if provided
# Delete old reset token if provided
if reset_key:
await session.execute(
delete(SessionModel).where(SessionModel.key == reset_key)
delete(ResetTokenModel).where(ResetTokenModel.key == reset_key)
)
# Optional rename
if display_name:
@@ -451,8 +570,9 @@ class DB(DatabaseInterface):
key=session_key,
user_uuid=user_uuid.bytes,
credential_uuid=credential.uuid.bytes,
expires=session_expires,
info=session_info,
host=host,
ip=ip,
user_agent=user_agent,
)
)
# Login side-effects: update user analytics (last_seen + visits increment)
@@ -475,17 +595,21 @@ class DB(DatabaseInterface):
self,
user_uuid: UUID,
key: bytes,
expires: datetime,
info: dict,
credential_uuid: UUID | None = None,
credential_uuid: UUID,
host: str,
ip: str,
user_agent: str,
renewed: datetime,
) -> None:
async with self.session() as session:
session_model = SessionModel(
key=key,
user_uuid=user_uuid.bytes,
credential_uuid=credential_uuid.bytes if credential_uuid else None,
expires=expires,
info=info,
credential_uuid=credential_uuid.bytes,
host=host,
ip=ip,
user_agent=user_agent,
renewed=renewed,
)
session.add(session_model)
@@ -496,29 +620,88 @@ class DB(DatabaseInterface):
session_model = result.scalar_one_or_none()
if session_model:
return Session(
key=session_model.key,
user_uuid=UUID(bytes=session_model.user_uuid),
credential_uuid=UUID(bytes=session_model.credential_uuid)
if session_model.credential_uuid
else None,
expires=session_model.expires,
info=session_model.info or {},
)
return session_model.as_dataclass()
return None
async def delete_session(self, key: bytes) -> None:
async with self.session() as session:
await session.execute(delete(SessionModel).where(SessionModel.key == key))
async def update_session(self, key: bytes, expires: datetime, info: dict) -> None:
async def delete_sessions_for_user(self, user_uuid: UUID) -> None:
async with self.session() as session:
await session.execute(
update(SessionModel)
.where(SessionModel.key == key)
.values(expires=expires, info=info)
delete(SessionModel).where(SessionModel.user_uuid == user_uuid.bytes)
)
async def create_reset_token(
self,
user_uuid: UUID,
key: bytes,
expiry: datetime,
token_type: str,
) -> None:
async with self.session() as session:
model = ResetTokenModel(
key=key,
user_uuid=user_uuid.bytes,
token_type=token_type,
expiry=expiry,
)
session.add(model)
async def get_reset_token(self, key: bytes) -> ResetToken | None:
async with self.session() as session:
stmt = select(ResetTokenModel).where(ResetTokenModel.key == key)
result = await session.execute(stmt)
model = result.scalar_one_or_none()
return model.as_dataclass() if model else None
async def delete_reset_token(self, key: bytes) -> None:
async with self.session() as session:
await session.execute(
delete(ResetTokenModel).where(ResetTokenModel.key == key)
)
async def update_session(
self,
key: bytes,
*,
ip: str,
user_agent: str,
renewed: datetime,
) -> Session | None:
async with self.session() as session:
model = await session.get(SessionModel, key)
if not model:
return None
model.ip = ip
model.user_agent = user_agent
model.renewed = renewed
await session.flush()
return model.as_dataclass()
async def set_session_host(self, key: bytes, host: str) -> None:
async with self.session() as session:
model = await session.get(SessionModel, key)
if model and model.host is None:
model.host = host
await session.flush()
async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]:
async with self.session() as session:
stmt = (
select(SessionModel)
.where(SessionModel.user_uuid == user_uuid.bytes)
.order_by(SessionModel.renewed.desc())
)
result = await session.execute(stmt)
session_models = [
model
for model in result.scalars().all()
if model.key.startswith(b"sess")
]
return [model.as_dataclass() for model in session_models]
# Organization operations
async def create_organization(self, org: Org) -> None:
async with self.session() as session:
@@ -971,8 +1154,10 @@ class DB(DatabaseInterface):
)
if role.permissions:
for perm_id in set(role.permissions):
session.add(
RolePermission(role_uuid=role.uuid.bytes, permission_id=perm_id)
await session.execute(
insert(RolePermission).values(
role_uuid=role.uuid.bytes, permission_id=perm_id
)
)
async def delete_role(self, role_uuid: UUID) -> None:
@@ -1112,11 +1297,18 @@ class DB(DatabaseInterface):
async def cleanup(self) -> None:
async with self.session() as session:
current_time = datetime.now()
stmt = delete(SessionModel).where(SessionModel.expires < current_time)
await session.execute(stmt)
current_time = datetime.now(timezone.utc)
session_threshold = current_time - SESSION_LIFETIME
await session.execute(
delete(SessionModel).where(SessionModel.renewed < session_threshold)
)
await session.execute(
delete(ResetTokenModel).where(ResetTokenModel.expiry < current_time)
)
async def get_session_context(self, session_key: bytes) -> SessionContext | None:
async def get_session_context(
self, session_key: bytes, host: str | None = None
) -> SessionContext | None:
"""Get complete session context including user, organization, role, and permissions.
Uses efficient JOINs to retrieve all related data in a single database query.
@@ -1153,15 +1345,18 @@ class DB(DatabaseInterface):
session_model, user_model, role_model, org_model, _ = first_row
# Create the session object
session_obj = Session(
key=session_model.key,
user_uuid=UUID(bytes=session_model.user_uuid),
credential_uuid=UUID(bytes=session_model.credential_uuid)
if session_model.credential_uuid
else None,
expires=session_model.expires,
info=session_model.info or {},
)
if host is not None:
if session_model.host is None:
await session.execute(
update(SessionModel)
.where(SessionModel.key == session_key)
.values(host=host)
)
session_model.host = host
elif session_model.host != host:
return None
session_obj = session_model.as_dataclass()
# Create the user object
user_obj = user_model.as_dataclass()
@@ -1200,10 +1395,15 @@ class DB(DatabaseInterface):
org_perm_result = await session.execute(org_perm_stmt)
organization.permissions = [row[0] for row in org_perm_result.fetchall()]
# Filter effective permissions: only include permissions that the org can grant
effective_permissions = [
p for p in permissions if p.id in organization.permissions
]
return SessionContext(
session=session_obj,
user=user_obj,
org=organization,
role=role,
permissions=permissions if permissions else None,
permissions=effective_permissions if effective_permissions else None,
)

View File

@@ -14,6 +14,27 @@ DEFAULT_SERVE_PORT = 4401
DEFAULT_DEV_PORT = 4402
def is_subdomain(sub: str, domain: str) -> bool:
"""Check if sub is a subdomain of domain (or equal)."""
sub_parts = sub.lower().split(".")
domain_parts = domain.lower().split(".")
if len(sub_parts) < len(domain_parts):
return False
return sub_parts[-len(domain_parts) :] == domain_parts
def validate_auth_host(auth_host: str, rp_id: str) -> None:
"""Validate that auth_host is a subdomain of rp_id."""
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
host = parsed.hostname or parsed.path
if not host:
raise SystemExit(f"Invalid auth-host: '{auth_host}'")
if not is_subdomain(host, rp_id):
raise SystemExit(
f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
)
def parse_endpoint(
value: str | None, default_port: int
) -> tuple[str | None, int | None, str | None, bool]:
@@ -94,6 +115,13 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
)
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
p.add_argument("--origin", help="Origin URL (default: https://<rp-id>)")
p.add_argument(
"--auth-host",
help=(
"Dedicated host (optionally with scheme/port) to serve the auth UI at the root,"
" e.g. auth.example.com or https://auth.example.com"
),
)
def main():
@@ -168,6 +196,17 @@ def main():
os.environ["PASSKEY_RP_NAME"] = args.rp_name
if origin:
os.environ["PASSKEY_ORIGIN"] = origin
if getattr(args, "auth_host", None):
os.environ["PASSKEY_AUTH_HOST"] = args.auth_host
else:
# Preserve pre-set env variable if CLI option omitted
args.auth_host = os.environ.get("PASSKEY_AUTH_HOST")
if args.auth_host:
validate_auth_host(args.auth_host, args.rp_id)
from passkey.util import hostutil as _hostutil # local import
_hostutil.reload_config()
# One-time initialization + bootstrap before starting any server processes.
# Lifespan in worker processes will call globals.init with bootstrap disabled.

View File

@@ -1,14 +1,24 @@
import logging
from datetime import timezone
from uuid import UUID, uuid4
from fastapi import Body, Cookie, FastAPI, HTTPException
from fastapi import Body, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from ..authsession import expires
from ..authsession import reset_expires
from ..globals import db
from ..globals import passkey as global_passkey
from ..util import frontend, passphrase, permutil, querysafe, tokens
from ..util import (
frontend,
hostutil,
passphrase,
permutil,
querysafe,
tokens,
useragent,
)
from ..util.tokens import encode_session_key, session_key
from . import authz
from .session import AUTH_COOKIE
app = FastAPI()
@@ -25,20 +35,36 @@ async def general_exception_handler(_request, exc: Exception):
@app.get("/")
async def adminapp(auth=Cookie(None)):
async def adminapp(request: Request, auth=AUTH_COOKIE):
"""Serve admin SPA only for authenticated users with admin/org permissions.
On missing/invalid session or insufficient permissions, serve restricted SPA.
"""
try:
await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any)
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("index.html"), status_code=e.status_code)
return FileResponse(
frontend.file("restricted", "index.html"), status_code=e.status_code
)
# -------------------- Organizations --------------------
@app.get("/orgs")
async def admin_list_orgs(auth=Cookie(None)):
ctx = await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any)
async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:*"],
match=permutil.has_any,
host=request.headers.get("host"),
)
orgs = await db.instance.list_organizations()
if "auth:admin" not in ctx.role.permissions:
orgs = [o for o in orgs if f"auth:org:{o.uuid}" in ctx.role.permissions]
@@ -74,60 +100,124 @@ async def admin_list_orgs(auth=Cookie(None)):
@app.post("/orgs")
async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)):
await authz.verify(auth, ["auth:admin"])
async def admin_create_org(
request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
):
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
from ..db import Org as OrgDC # local import to avoid cycles
from ..db import Role as RoleDC # local import to avoid cycles
org_uuid = uuid4()
display_name = payload.get("display_name") or "New Organization"
permissions = payload.get("permissions") or []
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
await db.instance.create_organization(org)
# Automatically create Administration role with org admin permission
role_uuid = uuid4()
admin_role = RoleDC(
uuid=role_uuid,
org_uuid=org_uuid,
display_name="Administration",
permissions=[f"auth:org:{org_uuid}"],
)
await db.instance.create_role(admin_role)
return {"uuid": str(org_uuid)}
@app.put("/orgs/{org_uuid}")
async def admin_update_org(
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
org_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
from ..db import Org as OrgDC # local import to avoid cycles
current = await db.instance.get_organization(str(org_uuid))
display_name = payload.get("display_name") or current.display_name
permissions = payload.get("permissions") or current.permissions or []
# Sanity check: prevent removing permissions that would break current user's admin access
org_admin_perm = f"auth:org:{org_uuid}"
# If current user is org admin (not global admin), ensure org admin perm remains
if (
"auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" in ctx.role.permissions
):
if org_admin_perm not in permissions:
raise ValueError(
"Cannot remove organization admin permission from your own organization"
)
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
await db.instance.update_organization(org)
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}")
async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
ctx = await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if ctx.org.uuid == org_uuid:
raise ValueError("Cannot delete the organization you belong to")
# Delete organization-specific permissions
org_perm_pattern = f"org:{str(org_uuid).lower()}"
all_permissions = await db.instance.list_permissions()
for perm in all_permissions:
perm_id_lower = perm.id.lower()
# Check if permission contains "org:{uuid}" separated by colons or at boundaries
if (
f":{org_perm_pattern}:" in perm_id_lower
or perm_id_lower.startswith(f"{org_perm_pattern}:")
or perm_id_lower.endswith(f":{org_perm_pattern}")
or perm_id_lower == org_perm_pattern
):
await db.instance.delete_permission(perm.id)
await db.instance.delete_organization(org_uuid)
return {"status": "ok"}
@app.post("/orgs/{org_uuid}/permission")
async def admin_add_org_permission(
org_uuid: UUID, permission_id: str, auth=Cookie(None)
org_uuid: UUID,
permission_id: str,
request: Request,
auth=AUTH_COOKIE,
):
await authz.verify(auth, ["auth:admin"])
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/permission")
async def admin_remove_org_permission(
org_uuid: UUID, permission_id: str, auth=Cookie(None)
org_uuid: UUID,
permission_id: str,
request: Request,
auth=AUTH_COOKIE,
):
await authz.verify(auth, ["auth:admin"])
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
await db.instance.remove_permission_from_organization(str(org_uuid), permission_id)
return {"status": "ok"}
@@ -137,9 +227,17 @@ async def admin_remove_org_permission(
@app.post("/orgs/{org_uuid}/roles")
async def admin_create_role(
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
org_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
await authz.verify(auth, ["auth:admin", f"auth:org:{org_uuid}"])
await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
from ..db import Role as RoleDC
role_uuid = uuid4()
@@ -163,11 +261,18 @@ async def admin_create_role(
@app.put("/orgs/{org_uuid}/roles/{role_uuid}")
async def admin_update_role(
org_uuid: UUID, role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
org_uuid: UUID,
role_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
# Verify caller is global admin or admin of provided org
await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
role = await db.instance.get_role(role_uuid)
if role.org_uuid != org_uuid:
@@ -175,13 +280,25 @@ async def admin_update_role(
from ..db import Role as RoleDC
display_name = payload.get("display_name") or role.display_name
permissions = payload.get("permissions") or role.permissions
permissions = payload.get("permissions")
if permissions is None:
permissions = role.permissions
org = await db.instance.get_organization(str(org_uuid))
grantable = set(org.permissions or [])
existing_permissions = set(role.permissions)
for pid in permissions:
await db.instance.get_permission(pid)
if pid not in grantable:
if pid not in existing_permissions and pid not in grantable:
raise ValueError(f"Permission not grantable by org: {pid}")
# Sanity check: prevent admin from removing their own access via role update
if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid:
has_admin_access = (
"auth:admin" in permissions or f"auth:org:{org_uuid}" in permissions
)
if not has_admin_access:
raise ValueError("Cannot update your own role to remove admin permissions")
updated = RoleDC(
uuid=role_uuid,
org_uuid=org_uuid,
@@ -193,13 +310,26 @@ async def admin_update_role(
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}")
async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)):
await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
async def admin_delete_role(
org_uuid: UUID,
role_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
role = await db.instance.get_role(role_uuid)
if role.org_uuid != org_uuid:
raise HTTPException(status_code=404, detail="Role not found in organization")
# Sanity check: prevent admin from deleting their own role
if ctx.role.uuid == role_uuid:
raise ValueError("Cannot delete your own role")
await db.instance.delete_role(role_uuid)
return {"status": "ok"}
@@ -209,10 +339,16 @@ async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)):
@app.post("/orgs/{org_uuid}/users")
async def admin_create_user(
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
org_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
display_name = payload.get("display_name")
role_name = payload.get("role")
@@ -238,10 +374,17 @@ async def admin_create_user(
@app.put("/orgs/{org_uuid}/users/{user_uuid}/role")
async def admin_update_user_role(
org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
org_uuid: UUID,
user_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
new_role = payload.get("role")
if not new_role:
@@ -255,13 +398,30 @@ async def admin_update_user_role(
roles = await db.instance.get_roles_by_organization(str(org_uuid))
if not any(r.display_name == new_role for r in roles):
raise ValueError("Role not found in organization")
# Sanity check: prevent admin from removing their own access
if ctx.user.uuid == user_uuid:
new_role_obj = next((r for r in roles if r.display_name == new_role), None)
if new_role_obj:
has_admin_access = (
"auth:admin" in new_role_obj.permissions
or f"auth:org:{org_uuid}" in new_role_obj.permissions
)
if not has_admin_access:
raise ValueError(
"Cannot change your own role to one without admin permissions"
)
await db.instance.update_user_role_in_organization(user_uuid, new_role)
return {"status": "ok"}
@app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link")
async def admin_create_user_registration_link(
org_uuid: UUID, user_uuid: UUID, auth=Cookie(None)
org_uuid: UUID,
user_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
try:
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
@@ -270,27 +430,49 @@ async def admin_create_user_registration_link(
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if (
"auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" not in ctx.role.permissions
):
raise HTTPException(status_code=403, detail="Insufficient permissions")
# Check if user has existing credentials
credentials = await db.instance.get_credentials_by_user_uuid(user_uuid)
token_type = "user registration" if not credentials else "account recovery"
token = passphrase.generate()
await db.instance.create_session(
expiry = reset_expires()
await db.instance.create_reset_token(
user_uuid=user_uuid,
key=tokens.reset_key(token),
expires=expires(),
info={"type": "device addition", "created_by_admin": True},
expiry=expiry,
token_type=token_type,
)
origin = global_passkey.instance.origin
url = f"{origin}/auth/{token}"
return {"url": url, "expires": expires().isoformat()}
url = hostutil.reset_link_url(
token, request.url.scheme, request.headers.get("host")
)
return {
"url": url,
"expires": (
expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
if expiry.tzinfo
else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
),
}
@app.get("/orgs/{org_uuid}/users/{user_uuid}")
async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(None)):
async def admin_get_user_detail(
org_uuid: UUID,
user_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
try:
user_org, role_name = await db.instance.get_user_organization(user_uuid)
except ValueError:
@@ -298,7 +480,10 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if (
"auth:admin" not in ctx.role.permissions
@@ -320,9 +505,41 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
{
"credential_uuid": str(c.uuid),
"aaguid": aaguid_str,
"created_at": c.created_at.isoformat(),
"last_used": c.last_used.isoformat() if c.last_used else None,
"last_verified": c.last_verified.isoformat()
"created_at": (
c.created_at.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.created_at.tzinfo
else c.created_at.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
),
"last_used": (
c.last_used.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.last_used and c.last_used.tzinfo
else (
c.last_used.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.last_used
else None
)
),
"last_verified": (
c.last_verified.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.last_verified and c.last_verified.tzinfo
else (
c.last_verified.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.last_verified
else None
)
)
if c.last_verified
else None,
"sign_count": c.sign_count,
@@ -331,21 +548,77 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
from .. import aaguid as aaguid_mod
aaguid_info = aaguid_mod.filter(aaguids)
# Get sessions for the user
normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
session_records = await db.instance.list_sessions_for_user(user_uuid)
current_session_key = session_key(auth)
sessions_payload: list[dict] = []
for entry in session_records:
sessions_payload.append(
{
"id": encode_session_key(entry.key),
"host": entry.host,
"ip": entry.ip,
"user_agent": useragent.compact_user_agent(entry.user_agent),
"last_renewed": (
entry.renewed.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if entry.renewed.tzinfo
else entry.renewed.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
),
"is_current": entry.key == current_session_key,
"is_current_host": bool(
normalized_request_host
and entry.host
and entry.host == normalized_request_host
),
}
)
return {
"display_name": user.display_name,
"org": {"display_name": user_org.display_name},
"role": role_name,
"visits": user.visits,
"created_at": user.created_at.isoformat() if user.created_at else None,
"last_seen": user.last_seen.isoformat() if user.last_seen else None,
"created_at": (
user.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
if user.created_at and user.created_at.tzinfo
else (
user.created_at.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if user.created_at
else None
)
),
"last_seen": (
user.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
if user.last_seen and user.last_seen.tzinfo
else (
user.last_seen.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if user.last_seen
else None
)
),
"credentials": creds,
"aaguid_info": aaguid_info,
"sessions": sessions_payload,
}
@app.put("/orgs/{org_uuid}/users/{user_uuid}/display-name")
async def admin_update_user_display_name(
org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
org_uuid: UUID,
user_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
try:
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
@@ -354,7 +627,10 @@ async def admin_update_user_display_name(
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if (
"auth:admin" not in ctx.role.permissions
@@ -370,19 +646,67 @@ async def admin_update_user_display_name(
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}")
async def admin_delete_user_credential(
org_uuid: UUID,
user_uuid: UUID,
credential_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
try:
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
except ValueError:
raise HTTPException(status_code=404, detail="User not found")
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if (
"auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" not in ctx.role.permissions
):
raise HTTPException(status_code=403, detail="Insufficient permissions")
await db.instance.delete_credential(credential_uuid, user_uuid)
return {"status": "ok"}
# -------------------- Permissions (global) --------------------
@app.get("/permissions")
async def admin_list_permissions(auth=Cookie(None)):
await authz.verify(auth, ["auth:admin"], match=permutil.has_any)
async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:*"],
match=permutil.has_any,
host=request.headers.get("host"),
)
perms = await db.instance.list_permissions()
return [{"id": p.id, "display_name": p.display_name} for p in perms]
# Global admins see all permissions
if "auth:admin" in ctx.role.permissions:
return [{"id": p.id, "display_name": p.display_name} for p in perms]
# Org admins only see permissions their org can grant
grantable = set(ctx.org.permissions or [])
filtered_perms = [p for p in perms if p.id in grantable]
return [{"id": p.id, "display_name": p.display_name} for p in filtered_perms]
@app.post("/permissions")
async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)):
await authz.verify(auth, ["auth:admin"])
async def admin_create_permission(
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
from ..db import Permission as PermDC
perm_id = payload.get("id")
@@ -396,9 +720,14 @@ async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)):
@app.put("/permission")
async def admin_update_permission(
permission_id: str, display_name: str, auth=Cookie(None)
permission_id: str,
display_name: str,
request: Request,
auth=AUTH_COOKIE,
):
await authz.verify(auth, ["auth:admin"])
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
from ..db import Permission as PermDC
if not display_name:
@@ -411,13 +740,24 @@ async def admin_update_permission(
@app.post("/permission/rename")
async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
await authz.verify(auth, ["auth:admin"])
async def admin_rename_permission(
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
old_id = payload.get("old_id")
new_id = payload.get("new_id")
display_name = payload.get("display_name")
if not old_id or not new_id:
raise ValueError("old_id and new_id required")
# Sanity check: prevent renaming critical permissions
if old_id == "auth:admin":
raise ValueError("Cannot rename the master admin permission")
querysafe.assert_safe(old_id, field="old_id")
querysafe.assert_safe(new_id, field="new_id")
if display_name is None:
@@ -431,8 +771,19 @@ async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
@app.delete("/permission")
async def admin_delete_permission(permission_id: str, auth=Cookie(None)):
await authz.verify(auth, ["auth:admin"])
async def admin_delete_permission(
permission_id: str,
request: Request,
auth=AUTH_COOKIE,
):
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
querysafe.assert_safe(permission_id, field="permission_id")
# Sanity check: prevent deleting critical permissions
if permission_id == "auth:admin":
raise ValueError("Cannot delete the master admin permission")
await db.instance.delete_permission(permission_id)
return {"status": "ok"}

View File

@@ -1,11 +1,8 @@
import logging
from contextlib import suppress
from datetime import datetime, timedelta
from uuid import UUID
from datetime import datetime, timedelta, timezone
from fastapi import (
Body,
Cookie,
Depends,
FastAPI,
HTTPException,
@@ -13,30 +10,43 @@ from fastapi import (
Request,
Response,
)
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from passkey.util import frontend
from passkey.util import frontend, useragent
from .. import aaguid
from ..authsession import (
EXPIRES,
delete_credential,
expires,
get_reset,
get_session,
refresh_session_token,
session_expiry,
)
from ..globals import db
from ..globals import passkey as global_passkey
from ..util import passphrase, permutil, tokens
from ..util.tokens import session_key
from . import authz, session
from ..util import hostutil, passphrase, permutil
from ..util.tokens import encode_session_key, session_key
from . import authz, session, user
from .session import AUTH_COOKIE
bearer_auth = HTTPBearer(auto_error=True)
app = FastAPI()
app.mount("/user", user.app)
@app.exception_handler(HTTPException)
async def http_exception_handler(_request: Request, exc: HTTPException):
"""Ensure auth cookie is cleared on 401 responses (JSON responses only)."""
if exc.status_code == 401:
resp = JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
session.clear_session_cookie(resp)
return resp
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
# Refresh only if at least this much of the session lifetime has been *consumed*.
# Consumption is derived from (now + EXPIRES) - current_expires.
# This guarantees a minimum spacing between DB writes even with frequent /validate calls.
@@ -56,7 +66,10 @@ async def general_exception_handler(_request: Request, exc: Exception):
@app.post("/validate")
async def validate_token(
response: Response, perm: list[str] = Query([]), auth=Cookie(None)
request: Request,
response: Response,
perm: list[str] = Query([]),
auth=AUTH_COOKIE,
):
"""Validate the current session and extend its expiry.
@@ -64,17 +77,26 @@ async def validate_token(
renewed max-age. This keeps active users logged in without needing a separate
refresh endpoint.
"""
ctx = await authz.verify(auth, perm)
try:
ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
except HTTPException:
# Global handler will clear cookie if 401
raise
renewed = False
if auth:
consumed = EXPIRES - (ctx.session.expires - datetime.now())
current_expiry = session_expiry(ctx.session)
consumed = EXPIRES - (current_expiry - datetime.now())
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
try:
await refresh_session_token(auth)
await refresh_session_token(
auth,
ip=request.client.host if request.client else "",
user_agent=request.headers.get("user-agent") or "",
)
session.set_session_cookie(response, auth)
renewed = True
except ValueError:
# Session disappeared, e.g. due to concurrent logout
# Session disappeared, e.g. due to concurrent logout; global handler will clear
raise HTTPException(status_code=401, detail="Session expired")
return {
"valid": True,
@@ -84,7 +106,12 @@ async def validate_token(
@app.get("/forward")
async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None)):
async def forward_authentication(
request: Request,
response: Response,
perm: list[str] = Query([]),
auth=AUTH_COOKIE,
):
"""Forward auth validation for Caddy/Nginx.
Query Params:
@@ -94,7 +121,7 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None))
Failure (unauthenticated / unauthorized): 4xx JSON body with detail.
"""
try:
ctx = await authz.verify(auth, perm)
ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
role_permissions = set(ctx.role.permissions or [])
if ctx.permissions:
role_permissions.update(permission.id for permission in ctx.permissions)
@@ -107,49 +134,83 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None))
"Remote-Org-Name": ctx.org.display_name,
"Remote-Role": str(ctx.role.uuid),
"Remote-Role-Name": ctx.role.display_name,
"Remote-Session-Expires": ctx.session.expires.isoformat(),
"Remote-Session-Expires": (
session_expiry(ctx.session)
.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if session_expiry(ctx.session).tzinfo
else session_expiry(ctx.session)
.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
),
"Remote-Credential": str(ctx.session.credential_uuid),
}
return Response(status_code=204, headers=remote_headers)
except HTTPException as e:
return FileResponse(frontend.file("index.html"), status_code=e.status_code)
# Let global handler clear cookie; still return HTML surface instead of JSON
html = frontend.file("restricted", "index.html").read_bytes()
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)
return Response(html, status_code=status, media_type="text/html")
@app.get("/settings")
async def get_settings():
pk = global_passkey.instance
return {"rp_id": pk.rp_id, "rp_name": pk.rp_name}
base_path = hostutil.ui_base_path()
return {
"rp_id": pk.rp_id,
"rp_name": pk.rp_name,
"ui_base_path": base_path,
"auth_host": hostutil.configured_auth_host(),
}
@app.post("/user-info")
async def api_user_info(reset: str | None = None, auth=Cookie(None)):
async def api_user_info(
request: Request,
response: Response,
reset: str | None = None,
auth=AUTH_COOKIE,
):
authenticated = False
session_record = None
reset_token = None
try:
if reset:
if not passphrase.is_well_formed(reset):
raise ValueError("Invalid reset token")
s = await get_reset(reset)
reset_token = await get_reset(reset)
target_user_uuid = reset_token.user_uuid
else:
if auth is None:
raise ValueError("Authentication Required")
s = await get_session(auth)
session_record = await get_session(auth, host=request.headers.get("host"))
authenticated = True
target_user_uuid = session_record.user_uuid
except ValueError as e:
raise HTTPException(401, str(e))
u = await db.instance.get_user_by_uuid(s.user_uuid)
u = await db.instance.get_user_by_uuid(target_user_uuid)
if not authenticated: # minimal response for reset tokens
if not authenticated and reset_token: # minimal response for reset tokens
return {
"authenticated": False,
"session_type": s.info.get("type"),
"session_type": reset_token.token_type,
"user": {"user_uuid": str(u.uuid), "user_name": u.display_name},
}
assert authenticated and auth is not None
assert auth is not None
assert session_record is not None
ctx = await permutil.session_context(auth)
credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid)
ctx = await permutil.session_context(auth, request.headers.get("host"))
credential_ids = await db.instance.get_credentials_by_user_uuid(
session_record.user_uuid
)
credentials: list[dict] = []
user_aaguids: set[str] = set()
for cred_id in credential_ids:
@@ -163,13 +224,45 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)):
{
"credential_uuid": str(c.uuid),
"aaguid": aaguid_str,
"created_at": c.created_at.isoformat(),
"last_used": c.last_used.isoformat() if c.last_used else None,
"last_verified": c.last_verified.isoformat()
"created_at": (
c.created_at.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.created_at.tzinfo
else c.created_at.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
),
"last_used": (
c.last_used.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.last_used and c.last_used.tzinfo
else (
c.last_used.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.last_used
else None
)
),
"last_verified": (
c.last_verified.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.last_verified and c.last_verified.tzinfo
else (
c.last_verified.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if c.last_verified
else None
)
)
if c.last_verified
else None,
"sign_count": c.sign_count,
"is_current_session": s.credential_uuid == c.uuid,
"is_current_session": session_record.credential_uuid == c.uuid,
}
)
credentials.sort(key=lambda cred: cred["created_at"])
@@ -193,19 +286,66 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)):
}
effective_permissions = [p.id for p in (ctx.permissions or [])]
is_global_admin = "auth:admin" in (role_info["permissions"] or [])
if org_info:
is_org_admin = f"auth:org:{org_info['uuid']}" in (
role_info["permissions"] or []
)
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,
"session_type": s.info.get("type"),
"user": {
"user_uuid": str(u.uuid),
"user_name": u.display_name,
"created_at": u.created_at.isoformat() if u.created_at else None,
"last_seen": u.last_seen.isoformat() if u.last_seen else None,
"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,
@@ -215,63 +355,31 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)):
"is_org_admin": is_org_admin,
"credentials": credentials,
"aaguid_info": aaguid_info,
"sessions": sessions_payload,
}
@app.put("/user/display-name")
async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)):
if not auth:
raise HTTPException(status_code=401, detail="Authentication Required")
s = await get_session(auth)
new_name = (payload.get("display_name") or "").strip()
if not new_name:
raise HTTPException(status_code=400, detail="display_name required")
if len(new_name) > 64:
raise HTTPException(status_code=400, detail="display_name too long")
await db.instance.update_user_display_name(s.user_uuid, new_name)
return {"status": "ok"}
@app.post("/logout")
async def api_logout(response: Response, auth=Cookie(None)):
async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
if not auth:
return {"message": "Already logged out"}
try:
await get_session(auth, host=request.headers.get("host"))
except ValueError:
return {"message": "Already logged out"}
with suppress(Exception):
await db.instance.delete_session(session_key(auth))
response.delete_cookie("auth")
session.clear_session_cookie(response)
return {"message": "Logged out successfully"}
@app.post("/set-session")
async def api_set_session(response: Response, auth=Depends(bearer_auth)):
user = await get_session(auth.credentials)
async def api_set_session(
request: Request, response: Response, auth=Depends(bearer_auth)
):
user = await get_session(auth.credentials, host=request.headers.get("host"))
session.set_session_cookie(response, auth.credentials)
return {
"message": "Session cookie set successfully",
"user_uuid": str(user.user_uuid),
}
@app.delete("/credential/{uuid}")
async def api_delete_credential(uuid: UUID, auth: str = Cookie(None)):
await delete_credential(uuid, auth)
return {"message": "Credential deleted successfully"}
@app.post("/create-link")
async def api_create_link(request: Request, auth=Cookie(None)):
s = await get_session(auth)
token = passphrase.generate()
await db.instance.create_session(
user_uuid=s.user_uuid,
key=tokens.reset_key(token),
expires=expires(),
info=session.infodict(request, "device addition"),
)
origin = global_passkey.instance.origin.rstrip("/")
url = f"{origin}/auth/{token}"
return {
"message": "Registration link generated successfully",
"url": url,
"expires": expires().isoformat(),
}

View File

@@ -0,0 +1,97 @@
"""Middleware for handling auth host redirects."""
from fastapi import Request, Response
from fastapi.responses import RedirectResponse
from passkey.util import hostutil, passphrase
def is_ui_path(path: str) -> bool:
"""Check if the path is a UI endpoint."""
ui_paths = {
"/",
"/admin",
"/admin/",
"/auth",
"/auth/",
"/auth/admin",
"/auth/admin/",
}
if path in ui_paths:
return True
# Treat reset token pages as UI (dynamic). Accept single-segment tokens.
if path.startswith("/auth/"):
token = path[6:]
if token and "/" not in token and passphrase.is_well_formed(token):
return True
else:
token = path[1:]
if token and "/" not in token and passphrase.is_well_formed(token):
return True
return False
def is_restricted_path(path: str) -> bool:
"""Check if the path is restricted (API/admin endpoints)."""
return path.startswith(("/auth/api/admin/", "/auth/api/user/", "/auth/ws/"))
def should_redirect_to_auth_host(path: str) -> bool:
"""Determine if the request should be redirected to the auth host."""
if path in {"/", "/auth", "/auth/"}:
return False
return is_ui_path(path) or is_restricted_path(path)
def redirect_to_auth_host(request: Request, cfg: str, path: str) -> Response:
"""Create a redirect response to the auth host."""
if is_restricted_path(path):
return Response(status_code=404)
new_path = (
path[5:] or "/" if is_ui_path(path) and path.startswith("/auth") else path
)
return RedirectResponse(f"{request.url.scheme}://{cfg}{new_path}", 307)
def should_redirect_auth_path_to_root(path: str) -> bool:
"""Check if /auth/ UI path should be redirected to root on auth host."""
if not path.startswith("/auth/"):
return False
ui_paths = {"/auth", "/auth/", "/auth/admin", "/auth/admin/"}
if path in ui_paths:
return True
# Check for reset token
token = path[6:]
return bool(token and "/" not in token and passphrase.is_well_formed(token))
def redirect_to_root_on_auth_host(request: Request, cur: str, path: str) -> Response:
"""Create a redirect response to root path on the same host."""
new_path = path[5:] or "/"
return RedirectResponse(f"{request.url.scheme}://{cur}{new_path}", 307)
async def redirect_middleware(request: Request, call_next):
"""Middleware to handle auth host redirects."""
cfg = hostutil.configured_auth_host()
if not cfg:
return await call_next(request)
cur = hostutil.normalize_host(request.headers.get("host"))
if not cur:
return await call_next(request)
cfg_normalized = hostutil.normalize_host(cfg)
on_auth_host = cur == cfg_normalized
path = request.url.path or "/"
if not on_auth_host:
if not should_redirect_to_auth_host(path):
return await call_next(request)
return redirect_to_auth_host(request, cfg, path)
else:
# On auth host: force UI endpoints at root
if should_redirect_auth_path_to_root(path):
return redirect_to_root_on_auth_host(request, cur, path)
return await call_next(request)

View File

@@ -7,7 +7,12 @@ from ..util import permutil
logger = logging.getLogger(__name__)
async def verify(auth: str | None, perm: list[str], match=permutil.has_all):
async def verify(
auth: str | None,
perm: list[str],
match=permutil.has_all,
host: str | None = None,
):
"""Validate session token and optional list of required permissions.
Returns the session context.
@@ -19,7 +24,7 @@ async def verify(auth: str | None, perm: list[str], match=permutil.has_all):
if not auth:
raise HTTPException(status_code=401, detail="Authentication required")
ctx = await permutil.session_context(auth)
ctx = await permutil.session_context(auth, host)
if not ctx:
raise HTTPException(status_code=401, detail="Session not found")

View File

@@ -2,13 +2,14 @@ import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from passkey.util import frontend, passphrase
from passkey.util import frontend, hostutil, passphrase
from . import admin, api, ws
from . import admin, api, auth_host, ws
from .session import AUTH_COOKIE
@asynccontextmanager
@@ -46,6 +47,10 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
app = FastAPI(lifespan=lifespan)
# Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
app.middleware("http")(auth_host.redirect_middleware)
app.mount("/auth/admin/", admin.app)
app.mount("/auth/api/", api.app)
app.mount("/auth/ws/", ws.app)
@@ -53,26 +58,59 @@ app.mount(
"/auth/assets/", StaticFiles(directory=frontend.file("assets")), name="assets"
)
# Navigable URLs are defined here. We support both / and /auth/ as the base path
# / is used on a dedicated auth site, /auth/ on app domains with auth
@app.get("/")
async def frontapp_redirect(request: Request):
"""Redirect root (in case accessed on backend) to the main authentication app."""
return RedirectResponse(request.url_for("frontapp"), status_code=303)
@app.get("/auth/")
async def frontapp():
"""Serve the main authentication app."""
return FileResponse(frontend.file("index.html"))
async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
"""Serve the user profile SPA only for authenticated sessions; otherwise restricted SPA.
Login / authentication UX is centralized in the restricted app.
"""
if not auth:
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("/auth/admin", include_in_schema=False)
async def admin_root_redirect():
return RedirectResponse(f"{hostutil.ui_base_path()}admin/", status_code=307)
@app.get("/admin/", include_in_schema=False)
async def admin_root(request: Request, auth=AUTH_COOKIE):
return await admin.adminapp(request, auth) # Delegated (enforces access control)
@app.get("/{reset}")
@app.get("/auth/{reset}")
async def reset_link(request: Request, reset: str):
"""Pretty URL for reset links."""
if reset == "admin":
# Admin app missing trailing slash lands here, be friendly to user
return RedirectResponse(request.url_for("adminapp"), status_code=303)
async def reset_link(reset: str):
"""Serve the SPA directly with an injected reset token."""
if not passphrase.is_well_formed(reset):
raise HTTPException(status_code=404)
url = request.url_for("frontapp").include_query_params(reset=reset)
return RedirectResponse(url, status_code=303)
return FileResponse(frontend.file("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"))

View File

@@ -17,7 +17,7 @@ from uuid import UUID
from passkey import authsession as _authsession
from passkey import globals as _g
from passkey.util import passphrase
from passkey.util import hostutil, passphrase
from passkey.util import tokens as _tokens
@@ -63,13 +63,14 @@ async def _resolve_targets(query: str | None):
async def _create_reset(user, role_name: str):
token = passphrase.generate()
await _g.db.instance.create_session(
expiry = _authsession.reset_expires()
await _g.db.instance.create_reset_token(
user_uuid=user.uuid,
key=_tokens.reset_key(token),
expires=_authsession.expires(),
info={"type": "manual reset", "role": role_name},
expiry=expiry,
token_type="manual reset",
)
return f"{_g.passkey.instance.origin}/auth/{token}", token
return hostutil.reset_link_url(token), token
async def _main(query: str | None) -> int:

View File

@@ -8,26 +8,45 @@ This module provides FastAPI-specific session management functionality:
Generic session management functions have been moved to authsession.py
"""
from fastapi import Request, Response, WebSocket
from fastapi import Cookie, Request, Response, WebSocket
from ..authsession import EXPIRES
AUTH_COOKIE_NAME = "__Host-auth"
AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME)
def infodict(request: Request | WebSocket, type: str) -> dict:
"""Extract client information from request."""
return {
"ip": request.client.host if request.client else "",
"user_agent": request.headers.get("user-agent", "")[:500],
"type": type,
"ip": request.client.host if request.client else None,
"user_agent": request.headers.get("user-agent", "")[:500] or None,
"session_type": type,
}
def set_session_cookie(response: Response, token: str) -> None:
"""Set the session token as an HTTP-only cookie."""
response.set_cookie(
key="auth",
key=AUTH_COOKIE_NAME,
value=token,
max_age=int(EXPIRES.total_seconds()),
httponly=True,
secure=True,
path="/",
samesite="lax",
)
def clear_session_cookie(response: Response) -> None:
# FastAPI's delete_cookie does not set the secure attribute
response.set_cookie(
key=AUTH_COOKIE_NAME,
value="",
max_age=0,
expires=0,
httponly=True,
secure=True,
path="/",
samesite="lax",
)

136
passkey/fastapi/user.py Normal file
View File

@@ -0,0 +1,136 @@
from datetime import timezone
from uuid import UUID
from fastapi import (
Body,
FastAPI,
HTTPException,
Request,
Response,
)
from ..authsession import (
delete_credential,
expires,
get_session,
)
from ..globals import db
from ..util import hostutil, passphrase, tokens
from ..util.tokens import decode_session_key, session_key
from . import session
from .session import AUTH_COOKIE
app = FastAPI()
@app.put("/display-name")
async def user_update_display_name(
request: Request,
response: Response,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
if not auth:
raise HTTPException(status_code=401, detail="Authentication Required")
try:
s = await get_session(auth, host=request.headers.get("host"))
except ValueError as e:
raise HTTPException(status_code=401, detail="Session expired") from e
new_name = (payload.get("display_name") or "").strip()
if not new_name:
raise HTTPException(status_code=400, detail="display_name required")
if len(new_name) > 64:
raise HTTPException(status_code=400, detail="display_name too long")
await db.instance.update_user_display_name(s.user_uuid, new_name)
return {"status": "ok"}
@app.post("/logout-all")
async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE):
if not auth:
return {"message": "Already logged out"}
try:
s = await get_session(auth, host=request.headers.get("host"))
except ValueError:
raise HTTPException(status_code=401, detail="Session expired")
await db.instance.delete_sessions_for_user(s.user_uuid)
session.clear_session_cookie(response)
return {"message": "Logged out from all hosts"}
@app.delete("/session/{session_id}")
async def api_delete_session(
request: Request,
response: Response,
session_id: str,
auth=AUTH_COOKIE,
):
if not auth:
raise HTTPException(status_code=401, detail="Authentication Required")
try:
current_session = await get_session(auth, host=request.headers.get("host"))
except ValueError as exc:
raise HTTPException(status_code=401, detail="Session expired") from exc
try:
target_key = decode_session_key(session_id)
except ValueError as exc:
raise HTTPException(
status_code=400, detail="Invalid session identifier"
) from exc
target_session = await db.instance.get_session(target_key)
if not target_session or target_session.user_uuid != current_session.user_uuid:
raise HTTPException(status_code=404, detail="Session not found")
await db.instance.delete_session(target_key)
current_terminated = target_key == session_key(auth)
if current_terminated:
session.clear_session_cookie(response) # explicit because 200
return {"status": "ok", "current_session_terminated": current_terminated}
@app.delete("/credential/{uuid}")
async def api_delete_credential(
request: Request,
response: Response,
uuid: UUID,
auth: str = AUTH_COOKIE,
):
try:
await delete_credential(uuid, auth, host=request.headers.get("host"))
except ValueError as e:
raise HTTPException(status_code=401, detail="Session expired") from e
return {"message": "Credential deleted successfully"}
@app.post("/create-link")
async def api_create_link(
request: Request,
response: Response,
auth=AUTH_COOKIE,
):
try:
s = await get_session(auth, host=request.headers.get("host"))
except ValueError as e:
raise HTTPException(status_code=401, detail="Session expired") from e
token = passphrase.generate()
expiry = expires()
await db.instance.create_reset_token(
user_uuid=s.user_uuid,
key=tokens.reset_key(token),
expiry=expiry,
token_type="device addition",
)
url = hostutil.reset_link_url(
token, request.url.scheme, request.headers.get("host")
)
return {
"message": "Registration link generated successfully",
"url": url,
"expires": (
expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
if expiry.tzinfo
else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
),
}

View File

@@ -2,14 +2,14 @@ import logging
from functools import wraps
from uuid import UUID
from fastapi import Cookie, FastAPI, WebSocket, WebSocketDisconnect
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
from ..authsession import create_session, expires, get_reset, get_session
from ..authsession import create_session, get_reset, get_session
from ..globals import db, passkey
from ..util import passphrase
from ..util.tokens import create_token, session_key
from .session import infodict
from .session import AUTH_COOKIE, infodict
# WebSocket error handling decorator
@@ -56,7 +56,10 @@ async def register_chat(
@app.websocket("/register")
@websocket_error_handler
async def websocket_register_add(
ws: WebSocket, reset: str | None = None, name: str | None = None, auth=Cookie(None)
ws: WebSocket,
reset: str | None = None,
name: str | None = None,
auth=AUTH_COOKIE,
):
"""Register a new credential for an existing user.
@@ -65,6 +68,7 @@ async def websocket_register_add(
- Reset token supplied as ?reset=... (auth cookie ignored)
"""
origin = ws.headers["origin"]
host = origin.split("://", 1)[1]
if reset is not None:
if not passphrase.is_well_formed(reset):
raise ValueError("Invalid reset token")
@@ -72,7 +76,7 @@ async def websocket_register_add(
else:
if not auth:
raise ValueError("Authentication Required")
s = await get_session(auth)
s = await get_session(auth, host=host)
user_uuid = s.user_uuid
# Get user information and determine effective user_name for this registration
@@ -89,14 +93,16 @@ async def websocket_register_add(
# Create a new session and store everything in database
token = create_token()
metadata = infodict(ws, "authenticated")
await db.instance.create_credential_session( # type: ignore[attr-defined]
user_uuid=user_uuid,
credential=credential,
reset_key=(s.key if reset is not None else None),
session_key=session_key(token),
session_expires=expires(),
session_info=infodict(ws, "authenticated"),
display_name=user_name,
host=host,
ip=metadata.get("ip"),
user_agent=metadata.get("user_agent"),
)
auth = token
@@ -115,6 +121,7 @@ async def websocket_register_add(
@websocket_error_handler
async def websocket_authenticate(ws: WebSocket):
origin = ws.headers["origin"]
host = origin.split("://", 1)[1]
options, challenge = passkey.instance.auth_generate_options()
await ws.send_json(options)
# Wait for the client to use his authenticator to authenticate
@@ -128,10 +135,13 @@ async def websocket_authenticate(ws: WebSocket):
# Create a session token for the authenticated user
assert stored_cred.uuid is not None
metadata = infodict(ws, "auth")
token = await create_session(
user_uuid=stored_cred.user_uuid,
info=infodict(ws, "auth"),
credential_uuid=stored_cred.uuid,
host=host,
ip=metadata.get("ip") or "",
user_agent=metadata.get("user_agent") or "",
)
await ws.send_json(

View File

@@ -8,7 +8,7 @@ This module provides a unified interface for WebAuthn operations including:
"""
import json
from datetime import datetime
from datetime import datetime, timezone
from urllib.parse import urlparse
from uuid import UUID
@@ -163,7 +163,7 @@ class Passkey:
aaguid=UUID(registration.aaguid),
public_key=registration.credential_public_key,
sign_count=registration.sign_count,
created_at=datetime.now(),
created_at=datetime.now(timezone.utc),
)
### Authentication Methods ###
@@ -227,7 +227,7 @@ class Passkey:
credential_current_sign_count=stored_cred.sign_count,
)
stored_cred.sign_count = verification.new_sign_count
now = datetime.now()
now = datetime.now(timezone.utc)
stored_cred.last_used = now
if verification.user_verified:
stored_cred.last_verified = now

92
passkey/util/hostutil.py Normal file
View File

@@ -0,0 +1,92 @@
"""Utilities for determining the auth UI host and base URLs."""
import os
from functools import lru_cache
from urllib.parse import urlparse, urlsplit
from ..globals import passkey as global_passkey
_AUTH_HOST_ENV = "PASSKEY_AUTH_HOST"
def _default_origin_scheme() -> str:
origin_url = urlparse(global_passkey.instance.origin)
return origin_url.scheme or "https"
@lru_cache(maxsize=1)
def _load_config() -> tuple[str | None, str] | None:
raw = os.getenv(_AUTH_HOST_ENV)
if not raw:
return None
candidate = raw.strip()
if not candidate:
return None
parsed = urlparse(candidate if "://" in candidate else f"//{candidate}")
netloc = parsed.netloc or parsed.path
if not netloc:
return None
return (parsed.scheme or None, netloc.strip("/"))
def configured_auth_host() -> str | None:
cfg = _load_config()
return cfg[1] if cfg else None
def is_root_mode() -> bool:
return _load_config() is not None
def ui_base_path() -> str:
return "/" if is_root_mode() else "/auth/"
def auth_site_base_url(scheme: str | None = None, host: str | None = None) -> str:
cfg = _load_config()
if cfg:
cfg_scheme, cfg_host = cfg
scheme_to_use = cfg_scheme or scheme or _default_origin_scheme()
netloc = cfg_host
else:
if host:
scheme_to_use = scheme or _default_origin_scheme()
netloc = host.strip("/")
else:
origin = global_passkey.instance.origin.rstrip("/")
return f"{origin}{ui_base_path()}"
base = f"{scheme_to_use}://{netloc}".rstrip("/")
path = ui_base_path().lstrip("/")
return f"{base}/{path}" if path else f"{base}/"
def reset_link_url(
token: str, scheme: str | None = None, host: str | None = None
) -> str:
base = auth_site_base_url(scheme, host)
return f"{base}{token}"
def reload_config() -> None:
_load_config.cache_clear()
def normalize_host(raw_host: str | None) -> str | None:
"""Normalize a Host header preserving port (exact match required)."""
if not raw_host:
return None
candidate = raw_host.strip()
if not candidate:
return None
# urlsplit to parse (add // for scheme-less); prefer netloc to retain port.
parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}")
netloc = parsed.netloc or parsed.path or ""
# Strip IPv6 brackets around host part but retain port suffix.
if netloc.startswith("["):
# format: [ipv6]:port or [ipv6]
if "]" in netloc:
host_part, _, rest = netloc.partition("]")
port_part = rest.lstrip(":")
netloc = host_part.strip("[]") + (f":{port_part}" if port_part else "")
return netloc.lower() or None

View File

@@ -4,6 +4,7 @@ from collections.abc import Sequence
from fnmatch import fnmatchcase
from ..globals import db
from .hostutil import normalize_host
from .tokens import session_key
__all__ = ["has_any", "has_all", "session_context"]
@@ -24,5 +25,8 @@ def has_all(ctx, patterns: Sequence[str]) -> bool:
return all(_match(ctx.role.permissions, patterns)) if ctx else False
async def session_context(auth: str | None):
return await db.instance.get_session_context(session_key(auth)) if auth else None
async def session_context(auth: str | None, host: str | None = None):
if not auth:
return None
normalized_host = normalize_host(host) if host else None
return await db.instance.get_session_context(session_key(auth), normalized_host)

View File

@@ -15,6 +15,25 @@ def session_key(token: str) -> bytes:
return b"sess" + base64.urlsafe_b64decode(token)
def encode_session_key(key: bytes) -> str:
"""Encode an opaque session key for external representation."""
return base64.urlsafe_b64encode(key).decode().rstrip("=")
def decode_session_key(encoded: str) -> bytes:
"""Decode an opaque session key from its public representation."""
if not encoded:
raise ValueError("Invalid session identifier")
padding = "=" * (-len(encoded) % 4)
try:
raw = base64.urlsafe_b64decode(encoded + padding)
except Exception as exc: # pragma: no cover - defensive
raise ValueError("Invalid session identifier") from exc
if not raw.startswith(b"sess"):
raise ValueError("Invalid session identifier")
return raw
def reset_key(passphrase: str) -> bytes:
if not is_well_formed(passphrase):
raise ValueError(

10
passkey/util/useragent.py Normal file
View File

@@ -0,0 +1,10 @@
import user_agents
def compact_user_agent(ua: str | None) -> str:
if not ua:
return "-"
u = user_agents.parse(ua)
ver = u.browser.version_string.split(".")[0]
dev = u.device.family if u.device.family not in ["Other", "Mac"] else ""
return f"{u.browser.family}/{ver} {u.os.family} {dev}".strip()

View File

@@ -1,10 +1,10 @@
[build-system]
requires = ["hatchling"]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
name = "passkey"
version = "0.1.0"
dynamic = ["version"]
description = "Passkey Authentication for Web Services"
authors = [
{name = "Leo Vasanko"},
@@ -18,9 +18,16 @@ dependencies = [
"aiosqlite>=0.19.0",
"uuid7-standard>=1.0.0",
"pyjwt>=2.8.0",
"user-agents>=2.2.0",
]
requires-python = ">=3.10"
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.hooks.vcs]
version-file = "passkey/_version.py"
[project.optional-dependencies]
dev = [
"ruff>=0.1.0",