Compare commits
	
		
			27 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 07525b47ae | ||
|   | 1ad1644b64 | ||
|   | 876215f1c1 | ||
|   | 59e7e40128 | ||
|   | a0da799c9e | ||
|   | 94efb00e34 | ||
|   | f9f4d59c6b | ||
|   | 45f9870d0d | ||
|   | 2a81544701 | ||
|   | a60c1bd5f5 | ||
|   | 229f066533 | ||
|   | 97f653e116 | ||
|   | 29be642dbe | ||
|   | bfb11cc20f | ||
|   | 389e05730b | ||
|   | 79b6c50a9c | ||
|   | 591ea626bf | ||
|   | 963ab06664 | ||
|   | bb35e57ba4 | ||
|   | 5d8304bbd9 | ||
|   | fbfd0bbb47 | ||
|   | eb38995cca | ||
|   | 382341e5ee | ||
|   | ed7d3ee0fc | ||
|   | 3dff459068 | ||
|   | 89b40cd080 | ||
|   | d46d50b91a | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -6,3 +6,4 @@ dist/ | |||||||
| passkey-auth.sqlite | passkey-auth.sqlite | ||||||
| /passkey/frontend-build | /passkey/frontend-build | ||||||
| /test_*.py | /test_*.py | ||||||
|  | passkey/_version.py | ||||||
|   | |||||||
							
								
								
									
										120
									
								
								API.md
									
									
									
									
									
								
							
							
						
						
									
										120
									
								
								API.md
									
									
									
									
									
								
							| @@ -1,28 +1,104 @@ | |||||||
| # PassKey Auth API Documentation | # 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 | Two deployment modes: | ||||||
| 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! |  | ||||||
|  |  | ||||||
| ### WebAuthn/Passkey endpoints (WebSockets) | 1. Multi‑host (default – no `--auth-host` provided) | ||||||
|  |    - All endpoints are reachable on any host under the `/auth/` prefix. | ||||||
|  |    - A convenience root (`/`) also serves the main app. | ||||||
|  |  | ||||||
| WS /auth/ws/register - Register new user with passkey | 2. Dedicated auth host (`--auth-host auth.example.com`) | ||||||
| WS /auth/ws/add_credential - Add new credential for existing user |    - The specified auth host serves the UI at the root (`/`, `/admin/`, reset tokens, etc.). | ||||||
| WS /auth/ws/authenticate - Authenticate user with passkey |    - Other (non‑auth) hosts show a lightweight account summary at `/` or `/auth/`, while other UI routes still redirect to the auth host. | ||||||
|  |    - Restricted endpoints on non‑auth hosts return `404` instead of redirecting. | ||||||
|  |  | ||||||
|  | ### Path Mapping When Auth Host Enabled | ||||||
|  |  | ||||||
|  | | Purpose | On Auth Host | On Other Hosts (incoming) | Action | | ||||||
|  | |---------|--------------|---------------------------|--------| | ||||||
|  | | Main UI | `/` | `/auth/` or `/` | Serve account summary SPA (no redirect) | | ||||||
|  | | Admin UI root | `/admin/` | `/auth/admin/` or `/admin/` | Redirect -> auth host `/admin/` (strip `/auth`) | | ||||||
|  | | Reset / device addition token | `/{token}` | `/auth/{token}` | Redirect -> auth host `/{token}` (strip `/auth`) | | ||||||
|  | | Static assets | `/auth/assets/*` | `/auth/assets/*` | Served directly (no redirect) | | ||||||
|  | | Unrestricted API | `/auth/api/...` | `/auth/api/...` | Served directly | | ||||||
|  | | Restricted API (admin,user,ws namespaces) | `/auth/api/{admin|user|ws}*` | same path | 404 on non‑auth hosts | | ||||||
|  | | WebSocket (register/auth) | `/auth/ws/*` | `/auth/ws/*` | 404 on non‑auth hosts | | ||||||
|  |  | ||||||
|  | Notes: | ||||||
|  | - “Strip `/auth`” means only when the path starts with that exact segment. | ||||||
|  | - A reset token is a single path segment validated by server logic; malformed tokens 404. | ||||||
|  | - Method and body are preserved for UI redirects (307 Temporary Redirect). | ||||||
|  |  | ||||||
|  | ## HTTP UI Endpoints | ||||||
|  |  | ||||||
|  | | Method | Path (multi‑host) | Path (auth host) | Description | | ||||||
|  | |--------|-------------------|------------------|-------------| | ||||||
|  | | GET | `/auth/` | `/` | Main authentication SPA (non-auth hosts show an account summary view) | | ||||||
|  | | GET | `/auth/admin/` | `/admin/` | Admin SPA root | | ||||||
|  | | GET | `/auth/{reset_token}` | `/{reset_token}` | Reset / device addition SPA (token validated) | | ||||||
|  | | 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 non‑auth hosts return 404: | ||||||
|  |  | ||||||
|  | | Namespace | Examples | | ||||||
|  | |-----------|----------| | ||||||
|  | | `/auth/api/admin` | `/auth/api/admin/orgs`, `/auth/api/admin/orgs/{uuid}` ... | | ||||||
|  | | `/auth/api/user` | Segment prefix – includes `/auth/api/user/...` endpoints (logout-all, display-name, session, credential) | | ||||||
|  | | `/auth/api/ws` | (Reserved / future) | | ||||||
|  |  | ||||||
|  | ## WebSockets (Passkey) | ||||||
|  |  | ||||||
|  | | Path | Description | Host Mode Behavior | | ||||||
|  | |------|-------------|--------------------| | ||||||
|  | | `/auth/ws/register` | Register new credential (new or existing user) | 404 on non‑auth hosts when auth host configured | | ||||||
|  | | `/auth/ws/authenticate` | Authenticate user & issue session | 404 on non‑auth hosts when auth host configured | | ||||||
|  |  | ||||||
|  | ## Redirection & Status Codes | ||||||
|  |  | ||||||
|  | | Scenario | Response | | ||||||
|  | |----------|----------| | ||||||
|  | | UI path on non‑auth host (auth host configured) | 307 redirect to auth host; `/auth` prefix stripped | | ||||||
|  | | Reset token UI path on non‑auth host | 307 redirect (token preserved) | | ||||||
|  | | Restricted API on non‑auth host | 404 | | ||||||
|  | | Unrestricted API on any host | Normal response | | ||||||
|  | | No auth host configured | All hosts behave like multi-host mode (no redirects; everything accessible) | | ||||||
|  |  | ||||||
|  | ## Headers for /auth/api/forward | ||||||
|  | See `Headers.md` for details of headers returned on success (204). | ||||||
|  |  | ||||||
|  | ## Notes for Integrators | ||||||
|  | 1. Always use absolute `/auth/api/...` paths for programmatic requests (they do not move when an auth host is introduced). | ||||||
|  | 2. Bookmark / deep links to UI should resolve correctly after redirection if users access via a non-auth application host. | ||||||
|  | 3. Treat 404 from restricted namespaces on non-auth hosts as a signal to direct users to the central auth site. | ||||||
|  |  | ||||||
|  | ## Environment & CLI Summary | ||||||
|  | | Option | Effect | | ||||||
|  | |--------|--------| | ||||||
|  | | `--auth-host` / `PASSKEY_AUTH_HOST` | Enables dedicated host mode, root-mounts UI there, restricts certain namespaces elsewhere | | ||||||
|  |  | ||||||
|  | --- | ||||||
|  | This document reflects current behavior of the middleware-based host routing logic. | ||||||
|   | |||||||
							
								
								
									
										12
									
								
								frontend/host/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								frontend/host/index.html
									
									
									
									
									
										Normal 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> | ||||||
| @@ -3,7 +3,7 @@ | |||||||
|   <head> |   <head> | ||||||
|     <meta charset="UTF-8"> |     <meta charset="UTF-8"> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> |     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||||
|     <title>Authentication</title> |     <title>Auth Profile</title> | ||||||
|   </head> |   </head> | ||||||
|   <body> |   <body> | ||||||
|     <div id="app"></div> |     <div id="app"></div> | ||||||
|   | |||||||
							
								
								
									
										12
									
								
								frontend/reset/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								frontend/reset/index.html
									
									
									
									
									
										Normal 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> | ||||||
							
								
								
									
										12
									
								
								frontend/restricted/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								frontend/restricted/index.html
									
									
									
									
									
										Normal 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> | ||||||
| @@ -1,59 +1,35 @@ | |||||||
| <template> | <template> | ||||||
|   <div> |   <div class="app-shell"> | ||||||
|     <StatusMessage /> |     <StatusMessage /> | ||||||
|     <LoginView v-if="store.currentView === 'login'" /> |     <main class="app-main"> | ||||||
|     <ProfileView v-if="store.currentView === 'profile'" /> |       <ProfileView v-if="initialized" /> | ||||||
|     <DeviceLinkView v-if="store.currentView === 'device-link'" /> |       <div v-else class="loading-container"> | ||||||
|     <ResetView v-if="store.currentView === 'reset'" /> |         <div class="loading-spinner"></div> | ||||||
|     <PermissionDeniedView v-if="store.currentView === 'permission-denied'" /> |         <p>Loading...</p> | ||||||
|  |       </div> | ||||||
|  |     </main> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup> | <script setup> | ||||||
| import { onMounted } from 'vue' | import { onMounted, ref } from 'vue' | ||||||
| import { useAuthStore } from '@/stores/auth' | import { useAuthStore } from '@/stores/auth' | ||||||
| import StatusMessage from '@/components/StatusMessage.vue' | import StatusMessage from '@/components/StatusMessage.vue' | ||||||
| import LoginView from '@/components/LoginView.vue' |  | ||||||
| import ProfileView from '@/components/ProfileView.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 store = useAuthStore() | ||||||
|  | const initialized = ref(false) | ||||||
|  |  | ||||||
| onMounted(async () => { | 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() |   await store.loadSettings() | ||||||
|   // Was an error message passed in the URL hash? |   if (store.settings?.rp_name) document.title = store.settings.rp_name | ||||||
|   const message = location.hash.substring(1) |   try { await store.loadUserInfo() } catch (_) { /* user info load errors ignored */ } | ||||||
|   if (message) { |   initialized.value = true | ||||||
|     store.showMessage(decodeURIComponent(message), 'error') |  | ||||||
|     history.replaceState(null, '', location.pathname) |  | ||||||
|   } |  | ||||||
|   // Capture reset token from query parameter and then remove it |  | ||||||
|   const params = new URLSearchParams(location.search) |  | ||||||
|   const reset = params.get('reset') |  | ||||||
|   if (reset) { |  | ||||||
|     store.resetToken = reset |  | ||||||
|     // Remove query param to avoid lingering in history / clipboard |  | ||||||
|     const targetPath = '/auth/' |  | ||||||
|     const currentPath = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/' |  | ||||||
|     history.replaceState(null, '', currentPath.startsWith('/auth') ? '/auth/' : targetPath) |  | ||||||
|   } |  | ||||||
|   try { |  | ||||||
|     await store.loadUserInfo() |  | ||||||
|   } catch (error) { |  | ||||||
|     console.log('Failed to load user info:', error) |  | ||||||
|     store.currentView = 'login' |  | ||||||
|   } |  | ||||||
|   store.selectView() |  | ||||||
| }) | }) | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .loading-container { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; gap: 1rem; } | ||||||
|  | .loading-spinner { width: 40px; height: 40px; border: 4px solid var(--color-border); border-top: 4px solid var(--color-primary); border-radius: 50%; animation: spin 1s linear infinite; } | ||||||
|  | @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } | ||||||
|  | .loading-container p { color: var(--color-text-muted); margin: 0; } | ||||||
|  | </style> | ||||||
|   | |||||||
| @@ -5,7 +5,12 @@ import CredentialList from '@/components/CredentialList.vue' | |||||||
| import UserBasicInfo from '@/components/UserBasicInfo.vue' | import UserBasicInfo from '@/components/UserBasicInfo.vue' | ||||||
| import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' | import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' | ||||||
| import StatusMessage from '@/components/StatusMessage.vue' | import StatusMessage from '@/components/StatusMessage.vue' | ||||||
|  | import AdminOverview from './AdminOverview.vue' | ||||||
|  | import AdminOrgDetail from './AdminOrgDetail.vue' | ||||||
|  | import AdminUserDetail from './AdminUserDetail.vue' | ||||||
|  | import AdminDialogs from './AdminDialogs.vue' | ||||||
| import { useAuthStore } from '@/stores/auth' | import { useAuthStore } from '@/stores/auth' | ||||||
|  | import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings' | ||||||
|  |  | ||||||
| const info = ref(null) | const info = ref(null) | ||||||
| const loading = ref(true) | const loading = ref(true) | ||||||
| @@ -20,15 +25,13 @@ const userLinkExpires = ref(null) | |||||||
| const authStore = useAuthStore() | const authStore = useAuthStore() | ||||||
| const addingOrgForPermission = ref(null) | const addingOrgForPermission = ref(null) | ||||||
| const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$' | const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$' | ||||||
| const showCreatePermission = ref(false) |  | ||||||
| const newPermId = ref('') |  | ||||||
| const newPermName = ref('') |  | ||||||
| const editingPermId = ref(null) | const editingPermId = ref(null) | ||||||
| const renameIdValue = ref('') | const renameIdValue = ref('') | ||||||
|  | const editingPermDisplay = ref(null) | ||||||
|  | const renameDisplayValue = ref('') | ||||||
| const dialog = ref({ type: null, data: null, busy: false, error: '' }) | const dialog = ref({ type: null, data: null, busy: false, error: '' }) | ||||||
| const safeIdRegex = /[^A-Za-z0-9:._~-]/g | 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 sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') } | ||||||
|  |  | ||||||
| function handleGlobalClick(e) { | function handleGlobalClick(e) { | ||||||
| @@ -52,7 +55,9 @@ const permissionSummary = computed(() => { | |||||||
|   const summary = {} |   const summary = {} | ||||||
|   for (const o of orgs.value) { |   for (const o of orgs.value) { | ||||||
|     const orgBase = { uuid: o.uuid, display_name: o.display_name } |     const orgBase = { uuid: o.uuid, display_name: o.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 || []) { |     for (const pid of o.permissions || []) { | ||||||
|       if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } |       if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } | ||||||
|       if (!summary[pid].orgSet.has(o.uuid)) { |       if (!summary[pid].orgSet.has(o.uuid)) { | ||||||
| @@ -60,9 +65,13 @@ const permissionSummary = computed(() => { | |||||||
|         summary[pid].orgSet.add(o.uuid) |         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 r of o.roles) { | ||||||
|       for (const pid of r.permissions) { |       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]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } | ||||||
|         if (!summary[pid].orgSet.has(o.uuid)) { |         if (!summary[pid].orgSet.has(o.uuid)) { | ||||||
|           summary[pid].orgs.push(orgBase) |           summary[pid].orgs.push(orgBase) | ||||||
| @@ -79,25 +88,7 @@ const permissionSummary = computed(() => { | |||||||
|   return display |   return display | ||||||
| }) | }) | ||||||
|  |  | ||||||
| function availableOrgsForPermission(pid) { | function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p, id: p.id, display_name: p.display_name }) } | ||||||
|   return orgs.value.filter(o => !o.permissions.includes(pid)) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p }) } |  | ||||||
|  |  | ||||||
| function startRenamePermissionId(p) { editingPermId.value = p.id; renameIdValue.value = p.id } |  | ||||||
| function cancelRenameId() { editingPermId.value = null; renameIdValue.value = '' } |  | ||||||
| async function submitRenamePermissionId(p) { |  | ||||||
|   const newId = renameIdValue.value.trim() |  | ||||||
|   if (!newId || newId === p.id) { cancelRenameId(); return } |  | ||||||
|   try { |  | ||||||
|     const body = { old_id: p.id, new_id: newId, display_name: p.display_name } |  | ||||||
|     const res = await fetch('/auth/admin/permission/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) |  | ||||||
|     let data; try { data = await res.json() } catch(_) { data = {} } |  | ||||||
|     if (!res.ok || data.detail) throw new Error(data.detail || data.error || `Failed (${res.status})`) |  | ||||||
|     await refreshPermissionsContext(); cancelRenameId() |  | ||||||
|   } catch (e) { authStore.showMessage(e?.message || 'Rename failed') } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| async function refreshPermissionsContext() { | async function refreshPermissionsContext() { | ||||||
|   // Reload both lists so All Permissions table shows new associations promptly. |   // Reload both lists so All Permissions table shows new associations promptly. | ||||||
| @@ -180,6 +171,7 @@ async function load() { | |||||||
|       if (!window.location.hash || window.location.hash === '#overview') { |       if (!window.location.hash || window.location.hash === '#overview') { | ||||||
|         currentOrgId.value = orgs.value[0].uuid |         currentOrgId.value = orgs.value[0].uuid | ||||||
|         window.location.hash = `#org/${currentOrgId.value}` |         window.location.hash = `#org/${currentOrgId.value}` | ||||||
|  |         authStore.showMessage(`Navigating to ${orgs.value[0].display_name} Administration`, 'info', 3000) | ||||||
|       } else { |       } else { | ||||||
|         parseHash() |         parseHash() | ||||||
|       } |       } | ||||||
| @@ -194,14 +186,16 @@ async function load() { | |||||||
| // Org actions | // Org actions | ||||||
| function createOrg() { openDialog('org-create', {}) } | 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) { | function deleteOrg(org) { | ||||||
|   if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return } |   if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return } | ||||||
|   openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => { |   openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => { | ||||||
|     const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' }) |     const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' }) | ||||||
|     const data = await res.json(); if (data.detail) throw new Error(data.detail) |     const data = await res.json(); if (data.detail) throw new Error(data.detail) | ||||||
|     await loadOrgs() |     await Promise.all([loadOrgs(), loadPermissions()]) | ||||||
|   } }) |   } }) | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -247,7 +241,7 @@ async function removeOrgPermission() { /* obsolete */ } | |||||||
| // Role actions | // Role actions | ||||||
| function createRole(org) { openDialog('role-create', { org }) } | 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) { | function deleteRole(role) { | ||||||
|   openDialog('confirm', { message: `Delete role ${role.display_name}?`, action: async () => { |   openDialog('confirm', { message: `Delete role ${role.display_name}?`, action: async () => { | ||||||
| @@ -257,17 +251,32 @@ function deleteRole(role) { | |||||||
|   } }) |   } }) | ||||||
| } | } | ||||||
|  |  | ||||||
| // Permission actions | async function toggleRolePermission(role, pid, checked) { | ||||||
| async function submitCreatePermission() { |   // Calculate new permissions array | ||||||
|   const id = newPermId.value.trim() |   const newPermissions = checked  | ||||||
|   const name = newPermName.value.trim() |     ? [...role.permissions, pid]  | ||||||
|   if (!id || !name) return |     : role.permissions.filter(p => p !== pid) | ||||||
|   const res = await fetch('/auth/admin/permissions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, display_name: name }) }) |  | ||||||
|   const data = await res.json(); if (data.detail) { authStore.showMessage(data.detail); return } |  | ||||||
|   await loadPermissions(); newPermId.value=''; newPermName.value=''; showCreatePermission.value=false |  | ||||||
| } |  | ||||||
| function cancelCreatePermission() { newPermId.value=''; newPermName.value=''; showCreatePermission.value=false } |  | ||||||
|    |    | ||||||
|  |   // 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 | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Permission actions | ||||||
| function updatePermission(p) { openDialog('perm-display', { permission: p }) } | function updatePermission(p) { openDialog('perm-display', { permission: p }) } | ||||||
|  |  | ||||||
| function deletePermission(p) { | function deletePermission(p) { | ||||||
| @@ -281,10 +290,8 @@ function deletePermission(p) { | |||||||
|  |  | ||||||
| onMounted(async () => { | onMounted(async () => { | ||||||
|   window.addEventListener('hashchange', parseHash) |   window.addEventListener('hashchange', parseHash) | ||||||
|   await authStore.loadSettings() |   const settings = await getSettings() | ||||||
|   if (authStore.settings?.rp_name) { |   if (settings?.rp_name) document.title = settings.rp_name + ' Admin' | ||||||
|     document.title = authStore.settings.rp_name + ' Admin' |  | ||||||
|   } |  | ||||||
|   load() |   load() | ||||||
| }) | }) | ||||||
|  |  | ||||||
| @@ -314,16 +321,16 @@ const selectedUser = computed(() => { | |||||||
| }) | }) | ||||||
|  |  | ||||||
| const pageHeading = computed(() => { | const pageHeading = computed(() => { | ||||||
|   if (selectedUser.value) return 'Organization Admin' |   if (selectedUser.value) return 'Admin: User' | ||||||
|   if (selectedOrg.value) return 'Organization Admin' |   if (selectedOrg.value) return 'Admin: Org' | ||||||
|   return (authStore.settings?.rp_name || 'Passkey') + ' Admin' |   return ((authStore.settings?.rp_name) || 'Master') + ' Admin' | ||||||
| }) | }) | ||||||
|  |  | ||||||
| // Breadcrumb entries for admin app. | // Breadcrumb entries for admin app. | ||||||
| const breadcrumbEntries = computed(() => { | const breadcrumbEntries = computed(() => { | ||||||
|   const entries = [ |   const entries = [ | ||||||
|     { label: 'Auth', href: '/auth/' }, |     { label: 'Auth', href: makeUiHref() }, | ||||||
|     { label: 'Admin', href: '/auth/admin/' } |     { label: 'Admin', href: adminUiPath() } | ||||||
|   ] |   ] | ||||||
|   // Determine organization for user view if selectedOrg not explicitly chosen. |   // Determine organization for user view if selectedOrg not explicitly chosen. | ||||||
|   let orgForUser = null |   let orgForUser = null | ||||||
| @@ -371,26 +378,24 @@ function permissionDisplayName(id) { | |||||||
|   return permissions.value.find(p => p.id === id)?.display_name || 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 |   // Build next permission list | ||||||
|   const has = role.permissions.includes(permId) |   const has = org.permissions.includes(permId) | ||||||
|   if (checked && has) return |   if (checked && has) return | ||||||
|   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 |   // Optimistic update | ||||||
|   const prev = [...role.permissions] |   const prev = [...org.permissions] | ||||||
|   role.permissions = next |   org.permissions = next | ||||||
|   try { |   try { | ||||||
|   const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { |     const params = new URLSearchParams({ permission_id: permId }) | ||||||
|       method: 'PUT', |     const res = await fetch(`/auth/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' }) | ||||||
|       headers: { 'content-type': 'application/json' }, |  | ||||||
|       body: JSON.stringify({ display_name: role.display_name, permissions: next }) |  | ||||||
|     }) |  | ||||||
|     const data = await res.json() |     const data = await res.json() | ||||||
|     if (data.detail) throw new Error(data.detail) |     if (data.detail) throw new Error(data.detail) | ||||||
|  |     await loadOrgs() | ||||||
|   } catch (e) { |   } catch (e) { | ||||||
|     authStore.showMessage(e.message || 'Failed to update role permission') |     authStore.showMessage(e.message || 'Failed to update organization permission') | ||||||
|     role.permissions = prev // revert |     org.permissions = prev // revert | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -429,19 +434,42 @@ async function submitDialog() { | |||||||
|       const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs() |       const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs() | ||||||
|     } else if (t === 'role-update') { |     } else if (t === 'role-update') { | ||||||
|       const { role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required') |       const { role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required') | ||||||
|       const permsCsv = dialog.value.data.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 perms = permsCsv.split(',').map(s=>s.trim()).filter(Boolean) |  | ||||||
|   const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: perms }) }) |  | ||||||
|       const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs() |       const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs() | ||||||
|     } else if (t === 'user-create') { |     } else if (t === 'user-create') { | ||||||
|       const { org, role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required') |       const { org, role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required') | ||||||
|       const res = await fetch(`/auth/admin/orgs/${org.uuid}/users`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, role: role.display_name }) }) |       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() |       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') { |     } 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 { permission } = dialog.value.data | ||||||
|       const params = new URLSearchParams({ permission_id: permission.id, display_name: display }) |       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 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 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') { |     } else if (t === 'confirm') { | ||||||
|       const action = dialog.value.data.action; if (action) await action() |       const action = dialog.value.data.action; if (action) await action() | ||||||
|     } |     } | ||||||
| @@ -453,435 +481,94 @@ async function submitDialog() { | |||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <div class="container"> |   <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> |             <h1>{{ pageHeading }}</h1> | ||||||
|             <Breadcrumbs :entries="breadcrumbEntries" /> |             <Breadcrumbs :entries="breadcrumbEntries" /> | ||||||
|     <div v-if="loading">Loading…</div> |           </header> | ||||||
|     <div v-else-if="error" class="error">{{ error }}</div> |  | ||||||
|     <div v-else> |           <section class="section-block admin-section"> | ||||||
|       <div v-if="!info?.authenticated"> |             <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> |                   <p>You must be authenticated.</p> | ||||||
|                 </div> |                 </div> | ||||||
|       <div v-else-if="!(info?.is_global_admin || info?.is_org_admin)"> |                 <div v-else-if="!(info?.is_global_admin || info?.is_org_admin)" class="surface surface--tight"> | ||||||
|                   <p>Insufficient permissions.</p> |                   <p>Insufficient permissions.</p> | ||||||
|                 </div> |                 </div> | ||||||
|       <div v-else> |                 <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" | ||||||
|   <div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card"> |                     :selected-user="selectedUser" | ||||||
|           <h2>Organizations</h2> |                     :user-detail="userDetail" | ||||||
|           <div class="actions"> |                     :selected-org="selectedOrg" | ||||||
|             <button @click="createOrg" v-if="info.is_global_admin">+ Create Org</button> |  | ||||||
|           </div> |  | ||||||
|           <table class="org-table"> |  | ||||||
|             <thead> |  | ||||||
|               <tr> |  | ||||||
|                 <th>Name</th> |  | ||||||
|                 <th>Roles</th> |  | ||||||
|                  <th>Members</th> |  | ||||||
|                 <th v-if="info.is_global_admin">Actions</th> |  | ||||||
|               </tr> |  | ||||||
|             </thead> |  | ||||||
|             <tbody> |  | ||||||
|               <tr v-for="o in orgs" :key="o.uuid"> |  | ||||||
|                 <td><a href="#org/{{o.uuid}}" @click.prevent="openOrg(o)">{{ o.display_name }}</a></td> |  | ||||||
|                 <td>{{ o.roles.length }}</td> |  | ||||||
|                 <td>{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td> |  | ||||||
|                 <td v-if="info.is_global_admin"> |  | ||||||
|                   <button @click="updateOrg(o)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button> |  | ||||||
|                   <button @click="deleteOrg(o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization">❌</button> |  | ||||||
|                 </td> |  | ||||||
|               </tr> |  | ||||||
|             </tbody> |  | ||||||
|           </table> |  | ||||||
|         </div> |  | ||||||
|         <div v-if="selectedUser" class="card user-detail"> |  | ||||||
|           <UserBasicInfo |  | ||||||
|             v-if="userDetail && !userDetail.error" |  | ||||||
|             :name="userDetail.display_name || selectedUser.display_name" |  | ||||||
|             :visits="userDetail.visits" |  | ||||||
|             :created-at="userDetail.created_at" |  | ||||||
|             :last-seen="userDetail.last_seen" |  | ||||||
|                     :loading="loading" |                     :loading="loading" | ||||||
|             :org-display-name="userDetail.org.display_name" |                     :show-reg-modal="showRegModal" | ||||||
|             :role-name="userDetail.role" |                     @generate-user-registration-link="generateUserRegistrationLink" | ||||||
|             :update-endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`" |                     @go-overview="goOverview" | ||||||
|             @saved="onUserNameSaved" |                     @open-org="openOrg" | ||||||
|  |                     @on-user-name-saved="onUserNameSaved" | ||||||
|  |                     @edit-user-name="editUserName" | ||||||
|  |                     @close-reg-modal="showRegModal = false" | ||||||
|                   /> |                   /> | ||||||
|           <div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div> |                   <AdminOrgDetail | ||||||
|           <template v-if="userDetail && !userDetail.error"> |                     v-else-if="selectedOrg" | ||||||
|             <h3 class="cred-title">Registered Passkeys</h3> |                     :selected-org="selectedOrg" | ||||||
|             <CredentialList :credentials="userDetail.credentials" :aaguid-info="userDetail.aaguid_info" /> |                     :permissions="permissions" | ||||||
|           </template> |                     @update-org="updateOrg" | ||||||
|           <div class="actions"> |                     @create-role="createRole" | ||||||
|             <button @click="generateUserRegistrationLink(selectedUser)">Generate Registration Token</button> |                     @update-role="updateRole" | ||||||
|             <button @click="goOverview" v-if="info.is_global_admin" class="icon-btn" title="Overview">🏠</button> |                     @delete-role="deleteRole" | ||||||
|             <button @click="openOrg(selectedOrg)" v-if="selectedOrg" class="icon-btn" title="Back to Org">↩️</button> |                     @create-user-in-role="createUserInRole" | ||||||
|           </div> |                     @open-user="openUser" | ||||||
|           <p class="matrix-hint muted">Use the token dialog to register a new credential for the member.</p> |                     @toggle-role-permission="toggleRolePermission" | ||||||
|           <RegistrationLinkModal |                     @on-role-drag-over="onRoleDragOver" | ||||||
|             v-if="showRegModal" |                     @on-role-drop="onRoleDrop" | ||||||
|             :endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`" |                     @on-user-drag-start="onUserDragStart" | ||||||
|             :auto-copy="false" |  | ||||||
|             @close="showRegModal = false" |  | ||||||
|             @copied="onLinkCopied" |  | ||||||
|                   /> |                   /> | ||||||
|  |  | ||||||
|                 </div> |                 </div> | ||||||
|  |               </template> | ||||||
|          |  | ||||||
|         <div v-else-if="selectedOrg" class="card"> |  | ||||||
|           <h2 class="org-title" :title="selectedOrg.uuid"> |  | ||||||
|             <span class="org-name">{{ selectedOrg.display_name }}</span> |  | ||||||
|             <button @click="updateOrg(selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button> |  | ||||||
|           </h2> |  | ||||||
|           <div class="org-actions"></div> |  | ||||||
|  |  | ||||||
|           <div class="matrix-wrapper"> |  | ||||||
|             <div class="matrix-scroll"> |  | ||||||
|               <div |  | ||||||
|                 class="perm-matrix-grid" |  | ||||||
|                 :style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + selectedOrg.roles.map(()=> '2.2rem').join(' ') + ' 2.2rem' }" |  | ||||||
|               > |  | ||||||
|                  |  | ||||||
|                 <div class="grid-head perm-head">Permission</div> |  | ||||||
|                 <div |  | ||||||
|                   v-for="r in selectedOrg.roles" |  | ||||||
|                   :key="'head-' + r.uuid" |  | ||||||
|                   class="grid-head role-head" |  | ||||||
|                   :title="r.display_name" |  | ||||||
|                 > |  | ||||||
|                   <span>{{ r.display_name }}</span> |  | ||||||
|             </div> |             </div> | ||||||
|                 <div class="grid-head role-head add-role-head" title="Add role" @click="createRole(selectedOrg)" role="button">➕</div> |           </section> | ||||||
|  |         </div> | ||||||
|                  |       </section> | ||||||
|                 <template v-for="pid in selectedOrg.permissions" :key="pid"> |     </main> | ||||||
|                   <div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div> |     <AdminDialogs | ||||||
|                   <div |       :dialog="dialog" | ||||||
|                     v-for="r in selectedOrg.roles" |       :permission-id-pattern="PERMISSION_ID_PATTERN" | ||||||
|                     :key="r.uuid + '-' + pid" |       @submit-dialog="submitDialog" | ||||||
|                     class="matrix-cell" |       @close-dialog="closeDialog" | ||||||
|                   > |  | ||||||
|                     <input |  | ||||||
|                       type="checkbox" |  | ||||||
|                       :checked="r.permissions.includes(pid)" |  | ||||||
|                       @change="e => toggleRolePermission(r, pid, e.target.checked)" |  | ||||||
|     /> |     /> | ||||||
|   </div> |   </div> | ||||||
|                   <div class="matrix-cell add-role-cell" /> |  | ||||||
|                 </template> |  | ||||||
|               </div> |  | ||||||
|             </div> |  | ||||||
|             <p class="matrix-hint muted">Toggle which permissions each role grants.</p> |  | ||||||
|           </div> |  | ||||||
|           <div class="roles-grid"> |  | ||||||
|             <div |  | ||||||
|               v-for="r in selectedOrg.roles" |  | ||||||
|               :key="r.uuid" |  | ||||||
|               class="role-column" |  | ||||||
|               @dragover="onRoleDragOver" |  | ||||||
|               @drop="e => onRoleDrop(e, selectedOrg, r)" |  | ||||||
|             > |  | ||||||
|               <div class="role-header"> |  | ||||||
|                 <strong class="role-name" :title="r.uuid"> |  | ||||||
|                   <span>{{ r.display_name }}</span> |  | ||||||
|                   <button @click="updateRole(r)" class="icon-btn" aria-label="Edit role" title="Edit role">✏️</button> |  | ||||||
|                 </strong> |  | ||||||
|                 <div class="role-actions"> |  | ||||||
|                   <button @click="createUserInRole(selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user">➕</button> |  | ||||||
|                 </div> |  | ||||||
|               </div> |  | ||||||
|               <template v-if="r.users.length > 0"> |  | ||||||
|                 <ul class="user-list"> |  | ||||||
|                   <li |  | ||||||
|                     v-for="u in r.users" |  | ||||||
|                     :key="u.uuid" |  | ||||||
|                     class="user-chip" |  | ||||||
|                     draggable="true" |  | ||||||
|                     @dragstart="e => onUserDragStart(e, u, selectedOrg.uuid)" |  | ||||||
|                     @click="openUser(u)" |  | ||||||
|                     :title="u.uuid" |  | ||||||
|                   > |  | ||||||
|                     <span class="name">{{ u.display_name }}</span> |  | ||||||
|                     <span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span> |  | ||||||
|                   </li> |  | ||||||
|                 </ul> |  | ||||||
|               </template> |  | ||||||
|               <div v-else class="empty-role"> |  | ||||||
|                 <p class="empty-text muted">No members</p> |  | ||||||
|                 <button @click="deleteRole(r)" class="icon-btn delete-icon" aria-label="Delete empty role" title="Delete role">❌</button> |  | ||||||
|               </div> |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|  |  | ||||||
|         <div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card"> |  | ||||||
|           <h2>All Permissions</h2> |  | ||||||
|           <div class="actions"> |  | ||||||
|             <button v-if="!showCreatePermission" @click="showCreatePermission = true">+ Create Permission</button> |  | ||||||
|             <form v-else class="inline-form" @submit.prevent="submitCreatePermission"> |  | ||||||
|               <input v-model="newPermId" @input="sanitizeNewId" required :pattern="PERMISSION_ID_PATTERN" placeholder="permission id" title="Allowed: A-Za-z0-9:._~-" /> |  | ||||||
|               <input v-model="newPermName" required placeholder="display name" /> |  | ||||||
|               <button type="submit">Save</button> |  | ||||||
|               <button type="button" @click="cancelCreatePermission">Cancel</button> |  | ||||||
|             </form> |  | ||||||
|           </div> |  | ||||||
|           <div class="permission-grid"> |  | ||||||
|             <div class="perm-grid-head">Permission</div> |  | ||||||
|             <div class="perm-grid-head">Orgs</div> |  | ||||||
|             <div class="perm-grid-head center">Members</div> |  | ||||||
|             <div class="perm-grid-head center">Actions</div> |  | ||||||
|             <template v-for="p in [...permissions].sort((a,b)=> a.id.localeCompare(b.id))" :key="p.id"> |  | ||||||
|               <div class="perm-cell perm-name" :title="p.id"> |  | ||||||
|                 <div class="perm-title-line">{{ p.display_name }}</div> |  | ||||||
|                 <div class="perm-id-line muted">{{ p.id }}</div> |  | ||||||
|               </div> |  | ||||||
|               <div class="perm-cell perm-orgs" :title="permissionSummary[p.id]?.orgs?.map(o=>o.display_name).join(', ') || ''"> |  | ||||||
|                 <template v-if="permissionSummary[p.id]"> |  | ||||||
|                   <span class="org-pill" v-for="o in permissionSummary[p.id].orgs" :key="o.uuid"> |  | ||||||
|                     {{ o.display_name }} |  | ||||||
|                     <button class="pill-x" @click.stop="detachPermissionFromOrg(p.id, o.uuid)" aria-label="Remove">×</button> |  | ||||||
|                   </span> |  | ||||||
|                 </template> |  | ||||||
|                 <span class="org-add-wrapper"> |  | ||||||
|                   <button |  | ||||||
|                     v-if="availableOrgsForPermission(p.id).length && addingOrgForPermission !== p.id" |  | ||||||
|                     class="add-org-btn" |  | ||||||
|                     @click.stop="addingOrgForPermission = p.id" |  | ||||||
|                     aria-label="Add organization" |  | ||||||
|                     title="Add organization" |  | ||||||
|                   >➕</button> |  | ||||||
|                   <div |  | ||||||
|                     v-if="addingOrgForPermission === p.id" |  | ||||||
|                     class="org-add-menu" |  | ||||||
|                     tabindex="0" |  | ||||||
|                     @keydown.escape.stop.prevent="addingOrgForPermission = null" |  | ||||||
|                   > |  | ||||||
|                     <div class="org-add-list"> |  | ||||||
|                       <button |  | ||||||
|                         v-for="o in availableOrgsForPermission(p.id)" |  | ||||||
|                         :key="o.uuid" |  | ||||||
|                         class="org-add-item" |  | ||||||
|                         @click.stop="attachPermissionToOrg(p.id, o.uuid); addingOrgForPermission = null" |  | ||||||
|                       >{{ o.display_name }}</button> |  | ||||||
|                     </div> |  | ||||||
|                     <div class="org-add-footer"> |  | ||||||
|                       <button class="org-add-cancel" @click.stop="addingOrgForPermission = null" aria-label="Cancel">Cancel</button> |  | ||||||
|                     </div> |  | ||||||
|                   </div> |  | ||||||
|                 </span> |  | ||||||
|               </div> |  | ||||||
|               <div class="perm-cell perm-users center">{{ permissionSummary[p.id]?.userCount || 0 }}</div> |  | ||||||
|               <div class="perm-cell perm-actions center"> |  | ||||||
|                 <div class="perm-actions-inner" :class="{ editing: editingPermId === p.id }"> |  | ||||||
|                   <div class="actions-view"> |  | ||||||
|                     <button @click="renamePermissionDisplay(p)" class="icon-btn" aria-label="Change display name" title="Change display name">✏️</button> |  | ||||||
|                     <button @click="startRenamePermissionId(p)" class="icon-btn" aria-label="Change id" title="Change id">🆔</button> |  | ||||||
|                     <button @click="deletePermission(p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission">❌</button> |  | ||||||
|                   </div> |  | ||||||
|                   <form class="inline-id-form overlay" @submit.prevent="submitRenamePermissionId(p)"> |  | ||||||
|                     <input v-model="renameIdValue" @input="sanitizeRenameId" required :pattern="PERMISSION_ID_PATTERN" class="id-input" title="Allowed: A-Za-z0-9:._~-" /> |  | ||||||
|                     <button type="submit" class="icon-btn" aria-label="Save">✔</button> |  | ||||||
|                     <button type="button" class="icon-btn" @click="cancelRenameId" aria-label="Cancel">✖</button> |  | ||||||
|                   </form> |  | ||||||
|                 </div> |  | ||||||
|               </div> |  | ||||||
|             </template> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
|   <StatusMessage /> |  | ||||||
|   <div v-if="dialog.type" class="modal-overlay" @keydown.esc.prevent.stop="closeDialog" tabindex="-1"> |  | ||||||
|     <div class="modal" role="dialog" aria-modal="true"> |  | ||||||
|       <h3 class="modal-title"> |  | ||||||
|         <template v-if="dialog.type==='org-create'">Create Organization</template> |  | ||||||
|         <template v-else-if="dialog.type==='org-update'">Rename Organization</template> |  | ||||||
|         <template v-else-if="dialog.type==='role-create'">Create Role</template> |  | ||||||
|         <template v-else-if="dialog.type==='role-update'">Edit Role</template> |  | ||||||
|         <template v-else-if="dialog.type==='user-create'">Add User To Role</template> |  | ||||||
|         <template v-else-if="dialog.type==='perm-display'">Edit Permission Display</template> |  | ||||||
|         <template v-else-if="dialog.type==='confirm'">Confirm</template> |  | ||||||
|       </h3> |  | ||||||
|       <form @submit.prevent="submitDialog" class="modal-form"> |  | ||||||
|         <template v-if="dialog.type==='org-create' || dialog.type==='org-update'"> |  | ||||||
|           <label>Name |  | ||||||
|             <input v-model="dialog.data.name" :placeholder="dialog.type==='org-update'? dialog.data.org.display_name : 'Organization name'" required /> |  | ||||||
|           </label> |  | ||||||
|         </template> |  | ||||||
|         <template v-else-if="dialog.type==='role-create'"> |  | ||||||
|           <label>Role Name |  | ||||||
|             <input v-model="dialog.data.name" placeholder="Role name" required /> |  | ||||||
|           </label> |  | ||||||
|         </template> |  | ||||||
|         <template v-else-if="dialog.type==='role-update'"> |  | ||||||
|           <label>Role Name |  | ||||||
|             <input v-model="dialog.data.name" :placeholder="dialog.data.role.display_name" required /> |  | ||||||
|           </label> |  | ||||||
|           <label>Permissions (comma separated) |  | ||||||
|             <textarea v-model="dialog.data.perms" rows="2" placeholder="perm:a, perm:b"></textarea> |  | ||||||
|           </label> |  | ||||||
|         </template> |  | ||||||
|         <template v-else-if="dialog.type==='user-create'"> |  | ||||||
|           <p class="small muted">Role: {{ dialog.data.role.display_name }}</p> |  | ||||||
|           <label>Display Name |  | ||||||
|             <input v-model="dialog.data.name" placeholder="User display name" required /> |  | ||||||
|           </label> |  | ||||||
|         </template> |  | ||||||
|         <template v-else-if="dialog.type==='perm-display'"> |  | ||||||
|           <p class="small muted">ID: {{ dialog.data.permission.id }}</p> |  | ||||||
|             <label>Display Name |  | ||||||
|               <input v-model="dialog.data.display_name" :placeholder="dialog.data.permission.display_name" required /> |  | ||||||
|             </label> |  | ||||||
|         </template> |  | ||||||
|         <template v-else-if="dialog.type==='confirm'"> |  | ||||||
|           <p>{{ dialog.data.message }}</p> |  | ||||||
|         </template> |  | ||||||
|         <div v-if="dialog.error" class="error small">{{ dialog.error }}</div> |  | ||||||
|         <div class="modal-actions"> |  | ||||||
|           <button type="submit" :disabled="dialog.busy">{{ dialog.type==='confirm' ? 'OK' : 'Save' }}</button> |  | ||||||
|           <button type="button" @click="closeDialog" :disabled="dialog.busy">Cancel</button> |  | ||||||
|         </div> |  | ||||||
|       </form> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <style scoped> | <style scoped> | ||||||
| .container { max-width: 960px; margin: 2rem auto; padding: 0 1rem; } | .view-admin { padding-bottom: var(--space-3xl); } | ||||||
| .subtitle { color: #888 } | .view-header { display: flex; flex-direction: column; gap: var(--space-sm); } | ||||||
| .card { margin: 1rem 0; padding: 1rem; border: 1px solid #eee; border-radius: 8px; } | .admin-section { margin-top: var(--space-xl); } | ||||||
| .error { color: #a00 } | .admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); } | ||||||
| .actions { margin-bottom: .5rem } | .admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); } | ||||||
| .org { border-top: 1px dashed #eee; padding: .5rem 0 } |  | ||||||
| .org-header { display: flex; gap: .5rem; align-items: baseline } |  | ||||||
| .user-item { display: flex; gap: .5rem; margin: .15rem 0 } |  | ||||||
| .users-table { width: 100%; border-collapse: collapse; margin-top: .25rem; } |  | ||||||
| .users-table th, .users-table td { padding: .25rem .4rem; text-align: left; border-bottom: 1px solid #eee; font-weight: normal; } |  | ||||||
| .users-table th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: #555; } |  | ||||||
| .users-table tbody tr:hover { background: #fafafa; } |  | ||||||
| .org-actions, .role-actions, .perm-actions { display: flex; gap: .5rem; margin: .25rem 0 } |  | ||||||
| .muted { color: #666 } |  | ||||||
| .small { font-size: .9em } |  | ||||||
| .pill-list { display: flex; flex-wrap: wrap; gap: .25rem } |  | ||||||
| .pill { background: #f3f3f3; border: 1px solid #e2e2e2; border-radius: 999px; padding: .1rem .5rem; display: inline-flex; align-items: center; gap: .25rem } |  | ||||||
| .pill-x { background: transparent; border: none; color: #900; cursor: pointer } |  | ||||||
| button { padding: .25rem .5rem; border-radius: 6px; border: 1px solid #ddd; background: #fff; cursor: pointer } |  | ||||||
| button:hover { background: #f7f7f7 } |  | ||||||
| /* Avoid global button 100% width from frontend main styles */ |  | ||||||
| button, .perm-actions button, .org-actions button, .role-actions button { width: auto; } |  | ||||||
| .roles-grid { display: flex; flex-wrap: wrap; gap: 1rem; align-items: stretch; padding: .5rem 0; } |  | ||||||
| .role-column { background: #fafafa; border: 1px solid #eee; border-radius: 8px; padding: .5rem; min-width: 200px; flex: 1 1 240px; display: flex; flex-direction: column; max-width: 300px; } |  | ||||||
| .role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .25rem } |  | ||||||
| .user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .25rem; flex: 1 1 auto; } |  | ||||||
| .user-chip { background: #fff; border: 1px solid #ddd; border-radius: 6px; padding: .25rem .4rem; display: flex; justify-content: space-between; gap: .5rem; cursor: grab; } |  | ||||||
| .user-chip:active { cursor: grabbing } |  | ||||||
| .user-chip .name { font-weight: 500 } |  | ||||||
| .user-chip .meta { font-size: .65rem; color: #666 } |  | ||||||
| .role-column.drag-over { outline: 2px dashed #66a; } |  | ||||||
| .org-table { width: 100%; border-collapse: collapse; } |  | ||||||
| .org-table th, .org-table td { padding: .4rem .5rem; border-bottom: 1px solid #eee; text-align: left; } |  | ||||||
| .org-table th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: #555; } |  | ||||||
| .org-table a { text-decoration: none; color: #0366d6; } |  | ||||||
| .org-table a:hover { text-decoration: underline; } |  | ||||||
| .nav-link { font-size: .6em; margin-left: .5rem; background: #eee; padding: .25em .6em; border-radius: 999px; border: 1px solid #ccc; text-decoration: none; } |  | ||||||
| .nav-link:hover { background: #ddd; } |  | ||||||
| .back-link { font-size: .5em; margin-left: .75rem; text-decoration: none; background: #eee; padding: .25em .6em; border-radius: 999px; border: 1px solid #ccc; vertical-align: middle; line-height: 1.2; } |  | ||||||
| .back-link:hover { background: #ddd; } |  | ||||||
| .matrix-wrapper { margin: 1rem 0; text-align: left; } |  | ||||||
| .matrix-scroll { overflow-x: auto; text-align: left; } |  | ||||||
| .perm-matrix-grid { display: inline-grid; gap: 0; align-items: stretch; margin-right: 4rem; } |  | ||||||
| .perm-matrix-grid > * { background: #fff; border: none; padding: .35rem .4rem; font-size: .75rem; } |  | ||||||
| .perm-matrix-grid .grid-head { background: transparent; border: none; font-size: .65rem; letter-spacing: .05em; font-weight: 600; text-transform: uppercase; display: flex; justify-content: center; align-items: flex-end; padding-bottom: .25rem; } |  | ||||||
| .perm-matrix-grid .perm-head { justify-content: flex-start; align-items: flex-end; } |  | ||||||
| .perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: .6rem; line-height: 1; } |  | ||||||
| .perm-matrix-grid .perm-name { font-weight: 500; white-space: nowrap; text-align: left; } |  | ||||||
| .perm-matrix-grid .matrix-cell { display: flex; justify-content: center; align-items: center; } |  | ||||||
| .perm-matrix-grid .matrix-cell input { cursor: pointer; } |  | ||||||
| .matrix-hint { font-size: .7rem; margin-top: .25rem; } |  | ||||||
| /* Add role column styles */ |  | ||||||
| .add-role-head { cursor: pointer; color: #2a6; font-size: 1rem; display:flex; justify-content:center; align-items:flex-end; } |  | ||||||
| .add-role-head:hover { color:#1c4; } |  | ||||||
| /* Removed add-role placeholder styles */ |  | ||||||
| /* Inline organization title with icon */ |  | ||||||
| .org-title { display: flex; align-items: center; gap: .4rem; } |  | ||||||
| .org-title .org-name { flex: 0 1 auto; } |  | ||||||
| /* Plus button for adding users */ |  | ||||||
| .plus-btn { background: none; border: none; font-size: 1.15rem; line-height: 1; padding: 0 .1rem; cursor: pointer; opacity: .6; } |  | ||||||
| .plus-btn:hover, .plus-btn:focus { opacity: 1; outline: none; } |  | ||||||
| .plus-btn:focus-visible { outline: 2px solid #555; outline-offset: 2px; } |  | ||||||
| .empty-role { display: flex; flex-direction: column; gap: .4rem; align-items: flex-start; padding: .35rem .25rem; /* removed flex grow & width for natural size */ } |  | ||||||
| .empty-role .empty-text { font-size: .7rem; margin: 0; } |  | ||||||
| .delete-icon { color: #c00; } |  | ||||||
| .delete-icon:hover, .delete-icon:focus { color: #ff0000; } |  | ||||||
| .user-detail .user-link-box { margin-top: .75rem; font-size: .7rem; background: #fff; border: 1px dashed #ccc; padding: .5rem; border-radius: 6px; cursor: pointer; word-break: break-all; } |  | ||||||
| .user-detail .user-link-box:hover { background: #f9f9f9; } |  | ||||||
| .user-detail .user-link-box .expires { font-size: .6rem; margin-top: .25rem; color: #555; } |  | ||||||
| /* Minimal icon button for rename/edit actions */ |  | ||||||
| .icon-btn { background: none; border: none; padding: 0 .15rem; margin-left: .15rem; cursor: pointer; font-size: .8rem; line-height: 1; opacity: .55; vertical-align: middle; } |  | ||||||
| .icon-btn:hover, .icon-btn:focus { opacity: .95; outline: none; } |  | ||||||
| .icon-btn:focus-visible { outline: 2px solid #555; outline-offset: 2px; } |  | ||||||
| .icon-btn:active { transform: translateY(1px); } |  | ||||||
| .org-title { display: flex; align-items: baseline; gap: .25rem; } |  | ||||||
| .role-name { display: inline-flex; align-items: center; gap: .15rem; font-weight: 600; } |  | ||||||
| .perm-name-line { display: flex; align-items: center; gap: .15rem; } |  | ||||||
| .user-meta { margin-top: .25rem; } |  | ||||||
| .cred-title { margin-top: .75rem; font-size: .85rem; } |  | ||||||
| .cred-list { list-style: none; padding: 0; margin: .25rem 0 .5rem; display: flex; flex-direction: column; gap: .35rem; } |  | ||||||
| .cred-item { background: #fff; border: 1px solid #eee; border-radius: 6px; padding: .35rem .5rem; font-size: .65rem; } |  | ||||||
| .cred-line { display: flex; flex-direction: column; gap: .15rem; } |  | ||||||
| .cred-line .dates { color: #555; font-size: .6rem; } |  | ||||||
| /* Permission grid */ |  | ||||||
| .permission-grid { display: grid; grid-template-columns: minmax(220px,2fr) minmax(160px,3fr) 70px 90px; gap: 2px; margin-top: .5rem; } |  | ||||||
| .permission-grid .perm-grid-head { font-size: .6rem; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; padding: .35rem .4rem; background: #f3f3f3; border: 1px solid #e1e1e1; } |  | ||||||
| .permission-grid .perm-cell { background: #fff; border: 1px solid #eee; padding: .35rem .4rem; font-size: .7rem; display: flex; align-items: center; gap: .4rem; } |  | ||||||
| .permission-grid .perm-name { flex-direction: row; flex-wrap: wrap; } |  | ||||||
| .permission-grid .perm-name { flex-direction: column; align-items: flex-start; gap:2px; } |  | ||||||
| .permission-grid .perm-title-line { font-weight:600; line-height:1.1; } |  | ||||||
| .permission-grid .perm-id-line { font-size:.55rem; line-height:1.1; word-break:break-all; } |  | ||||||
| .permission-grid .center { justify-content: center; } |  | ||||||
| .permission-grid .perm-actions { gap: .25rem; } |  | ||||||
| .permission-grid .perm-actions .icon-btn { font-size: .9rem; } |  | ||||||
| /* Inline edit overlay to avoid layout shift */ |  | ||||||
| .perm-actions-inner { position: relative; display:flex; width:100%; justify-content:center; } |  | ||||||
| .perm-actions-inner .inline-id-form.overlay { position:absolute; inset:0; display:none; align-items:center; justify-content:center; gap:.25rem; background:rgba(255,255,255,.9); backdrop-filter:blur(2px); padding:0 .15rem; } |  | ||||||
| .perm-actions-inner.editing .inline-id-form.overlay { display:inline-flex; } |  | ||||||
| .perm-actions-inner.editing .actions-view { visibility:hidden; } |  | ||||||
| /* Inline forms */ |  | ||||||
| .inline-form, .inline-id-form { display:inline-flex; gap:.25rem; align-items:center; } |  | ||||||
| .inline-form input, .inline-id-form input { padding:.25rem .4rem; font-size:.6rem; border:1px solid #ccc; border-radius:4px; } |  | ||||||
| .inline-form button, .inline-id-form button { font-size:.6rem; padding:.3rem .5rem; } |  | ||||||
| .inline-id-form .id-input { width:120px; } |  | ||||||
| /* Modal */ |  | ||||||
| .modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.4); display:flex; justify-content:center; align-items:flex-start; padding-top:8vh; z-index:200; } |  | ||||||
| .modal { background:#fff; border-radius:10px; padding:1rem 1.1rem; width: min(420px, 90%); box-shadow:0 10px 30px rgba(0,0,0,.25); animation:pop .18s ease; } |  | ||||||
| @keyframes pop { from { transform:translateY(10px); opacity:0 } to { transform:translateY(0); opacity:1 } } |  | ||||||
| .modal-title { margin:0 0 .65rem; font-size:1rem; } |  | ||||||
| .modal-form { display:flex; flex-direction:column; gap:.65rem; } |  | ||||||
| .modal-form label { display:flex; flex-direction:column; font-size:.65rem; gap:.25rem; font-weight:600; } |  | ||||||
| .modal-form input, .modal-form textarea { border:1px solid #ccc; border-radius:6px; padding:.45rem .55rem; font-size:.7rem; font-weight:400; font-family:inherit; } |  | ||||||
| .modal-form textarea { resize:vertical; } |  | ||||||
| .modal-actions { display:flex; gap:.5rem; justify-content:flex-end; margin-top:.25rem; } |  | ||||||
| .modal-actions button { font-size:.65rem; } |  | ||||||
| /* Org pill editing */ |  | ||||||
| .perm-orgs { flex-wrap: wrap; gap: .25rem; } |  | ||||||
| .perm-orgs .org-pill { background:#eef4ff; border:1px solid #d0dcf0; padding:2px 6px; border-radius:999px; font-size:.55rem; display:inline-flex; align-items:center; gap:4px; } |  | ||||||
| .perm-orgs .org-pill .pill-x { background:none; border:none; cursor:pointer; font-size:.7rem; line-height:1; padding:0; margin:0; color:#555; } |  | ||||||
| .perm-orgs .org-pill .pill-x:hover { color:#c00; } |  | ||||||
| .add-org-btn { background:none; border:none; cursor:pointer; font-size:.7rem; padding:0 2px; line-height:1; opacity:.55; display:inline; } |  | ||||||
| .add-org-btn:hover, .add-org-btn:focus { opacity:1; } |  | ||||||
| .add-org-btn:focus-visible { outline:2px solid #555; outline-offset:2px; } |  | ||||||
| .org-add-wrapper { position:relative; display:inline-block; } |  | ||||||
| .org-add-menu { position:absolute; top:100%; left:0; z-index:20; margin-top:4px; min-width:160px; background:#fff; border:1px solid #e2e6ea; border-radius:6px; padding:.3rem .35rem; box-shadow:0 4px 10px rgba(0,0,0,.08); display:flex; flex-direction:column; gap:.25rem; font-size:.6rem; } |  | ||||||
| .org-add-menu:before { content:""; position:absolute; top:-5px; left:10px; width:8px; height:8px; background:#fff; border-left:1px solid #e2e6ea; border-top:1px solid #e2e6ea; transform:rotate(45deg); } |  | ||||||
| .org-add-list { display:flex; flex-direction:column; gap:0; max-height:180px; overflow-y:auto; scrollbar-width:thin; } |  | ||||||
| .org-add-item { background:transparent; border:none; padding:.25rem .4rem; font-size:.6rem; border-radius:4px; cursor:pointer; line-height:1.1; text-align:left; width:100%; color:#222; } |  | ||||||
| .org-add-item:hover, .org-add-item:focus { background:#f2f5f9; } |  | ||||||
| .org-add-item:active { background:#e6ebf0; } |  | ||||||
| .org-add-footer { margin-top:.25rem; display:flex; justify-content:flex-end; } |  | ||||||
| .org-add-cancel { background:transparent; border:none; font-size:.55rem; padding:.15rem .35rem; cursor:pointer; color:#666; border-radius:4px; } |  | ||||||
| .org-add-cancel:hover, .org-add-cancel:focus { background:#f2f5f9; color:#222; } |  | ||||||
| .org-add-cancel:active { background:#e6ebf0; } |  | ||||||
| </style> | </style> | ||||||
|   | |||||||
							
								
								
									
										128
									
								
								frontend/src/admin/AdminDialogs.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								frontend/src/admin/AdminDialogs.vue
									
									
									
									
									
										Normal 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> | ||||||
							
								
								
									
										157
									
								
								frontend/src/admin/AdminOrgDetail.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								frontend/src/admin/AdminOrgDetail.vue
									
									
									
									
									
										Normal 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> | ||||||
							
								
								
									
										165
									
								
								frontend/src/admin/AdminOverview.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								frontend/src/admin/AdminOverview.vue
									
									
									
									
									
										Normal 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> | ||||||
							
								
								
									
										118
									
								
								frontend/src/admin/AdminUserDetail.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								frontend/src/admin/AdminUserDetail.vue
									
									
									
									
									
										Normal 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
											
										
									
								
							| @@ -29,14 +29,10 @@ const crumbs = computed(() => { | |||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <style scoped> | <style scoped> | ||||||
| .breadcrumbs { margin: .25rem 0 .5rem; line-height:1.2; } | .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; } | .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; } | .breadcrumbs li { display: inline-flex; align-items: center; gap: .25rem; font-size: .9rem; } | ||||||
| .breadcrumbs a { text-decoration: none; color: #0366d6; padding: 0 .15rem; border-radius:4px; } | .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 { text-decoration: underline; } | .breadcrumbs a:hover, .breadcrumbs a:focus-visible { text-decoration: underline; color: var(--color-link-hover); outline: none; } | ||||||
| .breadcrumbs .sep { color: #888; margin: 0 .1rem; } | .breadcrumbs .sep { color: var(--color-text-muted); margin: 0; } | ||||||
| @media (prefers-color-scheme: dark) { |  | ||||||
|   .breadcrumbs a { color: #4ea3ff; } |  | ||||||
|   .breadcrumbs .sep { color: #aaa; } |  | ||||||
| } |  | ||||||
| </style> | </style> | ||||||
|   | |||||||
| @@ -2,14 +2,14 @@ | |||||||
|   <div class="credential-list"> |   <div class="credential-list"> | ||||||
|     <div v-if="loading"><p>Loading credentials...</p></div> |     <div v-if="loading"><p>Loading credentials...</p></div> | ||||||
|     <div v-else-if="!credentials?.length"><p>No passkeys found.</p></div> |     <div v-else-if="!credentials?.length"><p>No passkeys found.</p></div> | ||||||
|     <div v-else> |     <template v-else> | ||||||
|       <div |       <div | ||||||
|         v-for="credential in credentials" |         v-for="credential in credentials" | ||||||
|         :key="credential.credential_uuid" |         :key="credential.credential_uuid" | ||||||
|         :class="['credential-item', { 'current-session': credential.is_current_session }]" |         :class="['credential-item', { 'current-session': credential.is_current_session } ]" | ||||||
|       > |       > | ||||||
|         <div class="credential-header"> |         <div class="item-top"> | ||||||
|           <div class="credential-icon"> |           <div class="item-icon"> | ||||||
|             <img |             <img | ||||||
|               v-if="getCredentialAuthIcon(credential)" |               v-if="getCredentialAuthIcon(credential)" | ||||||
|               :src="getCredentialAuthIcon(credential)" |               :src="getCredentialAuthIcon(credential)" | ||||||
| @@ -20,27 +20,31 @@ | |||||||
|             > |             > | ||||||
|             <span v-else class="auth-emoji">🔑</span> |             <span v-else class="auth-emoji">🔑</span> | ||||||
|           </div> |           </div> | ||||||
|           <div class="credential-info"> |           <h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4> | ||||||
|             <h4>{{ getCredentialAuthName(credential) }}</h4> |           <div class="item-actions"> | ||||||
|           </div> |             <span v-if="credential.is_current_session" class="badge badge-current">Current</span> | ||||||
|           <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"> |  | ||||||
|             <button |             <button | ||||||
|  |               v-if="allowDelete" | ||||||
|               @click="$emit('delete', credential)" |               @click="$emit('delete', credential)" | ||||||
|               class="btn-delete-credential" |               class="btn-card-delete" | ||||||
|               :disabled="credential.is_current_session" |               :disabled="credential.is_current_session" | ||||||
|               :title="credential.is_current_session ? 'Cannot delete current session credential' : 'Delete passkey'" |               :title="credential.is_current_session ? 'Cannot delete current session credential' : 'Delete passkey'" | ||||||
|             >🗑️</button> |             >🗑️</button> | ||||||
|           </div> |           </div> | ||||||
|         </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> | ||||||
|       </div> |       </div> | ||||||
|  |     </template> | ||||||
|  |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup> | <script setup> | ||||||
| @@ -67,18 +71,3 @@ const getCredentialAuthIcon = (credential) => { | |||||||
|   return info[iconKey] || null |   return info[iconKey] || null | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <style scoped> |  | ||||||
| .credential-list { display: flex; flex-direction: column; gap: .75rem; margin-top: .5rem; } |  | ||||||
| .credential-item { border: 1px solid #ddd; border-radius: 8px; padding: .5rem .75rem; background: #fff; } |  | ||||||
| .credential-header { display: flex; align-items: center; gap: 1rem; } |  | ||||||
| .credential-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; } |  | ||||||
| .auth-icon { border-radius: 6px; } |  | ||||||
| .credential-info { flex: 1 1 auto; } |  | ||||||
| .credential-info h4 { margin: 0; font-size: .9rem; } |  | ||||||
| .credential-dates { display: grid; grid-auto-flow: column; gap: .4rem; font-size: .65rem; align-items: center; } |  | ||||||
| .date-label { font-weight: 600; } |  | ||||||
| .credential-actions { margin-left: auto; } |  | ||||||
| .btn-delete-credential { background: none; border: none; cursor: pointer; font-size: .9rem; } |  | ||||||
| .btn-delete-credential:disabled { opacity: .3; cursor: not-allowed; } |  | ||||||
| </style> |  | ||||||
|   | |||||||
| @@ -1,67 +1,73 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="container"> |   <section class="view-root view-device-link"> | ||||||
|     <div class="view active"> |     <div class="view-content view-content--narrow"> | ||||||
|  |       <header class="view-header"> | ||||||
|         <h1>📱 Add Another Device</h1> |         <h1>📱 Add Another Device</h1> | ||||||
|       <div class="device-link-section"> |         <p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p> | ||||||
|         <div class="qr-container"> |       </header> | ||||||
|           <a :href="url" id="deviceLinkText" @click="copyLink"> |       <RegistrationLinkModal | ||||||
|             <canvas id="qrCode" class="qr-code"></canvas> |         inline | ||||||
|             <p v-if="url"> |                 :endpoint="'/auth/api/user/create-link'" | ||||||
|               {{ url.replace(/^[^:]+:\/\//, '') }} |         :user-name="userName" | ||||||
|             </p> |         :auto-copy="false" | ||||||
|             <p v-else> |         :prefix-copy-with-user-name="!!userName" | ||||||
|               <em>Generating link...</em> |         show-close-in-inline | ||||||
|             </p> |         @copied="onCopied" | ||||||
|           </a> |       /> | ||||||
|           <p> |       <div class="button-row" style="margin-top:1rem;"> | ||||||
|             <strong>Scan and visit the URL on another device.</strong><br> |         <button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button> | ||||||
|             <small>⚠️ Expires in 24 hours and can only be used once.</small> |  | ||||||
|           </p> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|  |  | ||||||
|       <button @click="authStore.currentView = 'profile'" class="btn-secondary"> |  | ||||||
|         Back to Profile |  | ||||||
|       </button> |  | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|  |   </section> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup> | <script setup> | ||||||
| import { ref, onMounted } from 'vue' | import { ref, onMounted } from 'vue' | ||||||
| import { useAuthStore } from '@/stores/auth' | import { useAuthStore } from '@/stores/auth' | ||||||
| import QRCode from 'qrcode/lib/browser' | import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' | ||||||
|  |  | ||||||
| const authStore = useAuthStore() | const authStore = useAuthStore() | ||||||
| const url = ref(null) | const userName = ref(null) | ||||||
|  | const onCopied = () => { | ||||||
| const copyLink = async (event) => { |   authStore.showMessage('Link copied to clipboard!', 'success', 2500) | ||||||
|   event.preventDefault() |  | ||||||
|   if (url.value) { |  | ||||||
|     await navigator.clipboard.writeText(url.value) |  | ||||||
|     authStore.showMessage('Link copied to clipboard!') |  | ||||||
|   authStore.currentView = 'profile' |   authStore.currentView = 'profile' | ||||||
|   } |  | ||||||
| } | } | ||||||
|  |  | ||||||
| onMounted(async () => { | onMounted(async () => { | ||||||
|   try { |   // Extract optional admin-provided query parameters (?user=Name&emoji=😀) | ||||||
|   const response = await fetch('/auth/api/create-link', { method: 'POST' }) |   const params = new URLSearchParams(location.search) | ||||||
|     const result = await response.json() |   const qUser = params.get('user') | ||||||
|     if (result.detail) throw new Error(result.detail) |   if (qUser) userName.value = qUser.trim() | ||||||
|  |  | ||||||
|     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' |  | ||||||
|   } |  | ||||||
| }) | }) | ||||||
|  |  | ||||||
| </script> | </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> | ||||||
|   | |||||||
| @@ -1,42 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <div class="container"> |  | ||||||
|     <div class="view active"> |  | ||||||
|   <h1>🔐 {{ (authStore.settings?.rp_name || 'Passkey') + ' Login' }}</h1> |  | ||||||
|       <form @submit.prevent="handleLogin"> |  | ||||||
|         <button |  | ||||||
|           type="submit" |  | ||||||
|           class="btn-primary" |  | ||||||
|           :disabled="authStore.isLoading" |  | ||||||
|         > |  | ||||||
|           {{ authStore.isLoading ? 'Authenticating...' : 'Login with Your Device' }} |  | ||||||
|         </button> |  | ||||||
|       </form> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <script setup> |  | ||||||
| import { useAuthStore } from '@/stores/auth' |  | ||||||
| import { computed } from 'vue' |  | ||||||
|  |  | ||||||
| const authStore = useAuthStore() |  | ||||||
|  |  | ||||||
| const handleLogin = async () => { |  | ||||||
|   try { |  | ||||||
|     console.log('Login button clicked') |  | ||||||
|     authStore.showMessage('Starting authentication...', 'info') |  | ||||||
|     await authStore.authenticate() |  | ||||||
|     authStore.showMessage('Authentication successful!', 'success', 2000) |  | ||||||
|     if (authStore.restrictedMode) { |  | ||||||
|       // Restricted mode: reload so the app re-mounts and selectView() applies (will become permission denied) |  | ||||||
|       location.reload() |  | ||||||
|     } else if (location.pathname === '/auth/') { |  | ||||||
|       authStore.currentView = 'profile' |  | ||||||
|     } else { |  | ||||||
|       location.reload() |  | ||||||
|     } |  | ||||||
|   } catch (error) { |  | ||||||
|     authStore.showMessage(error.message, 'error') |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </script> |  | ||||||
							
								
								
									
										93
									
								
								frontend/src/components/Modal.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								frontend/src/components/Modal.vue
									
									
									
									
									
										Normal 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> | ||||||
							
								
								
									
										94
									
								
								frontend/src/components/NameEditForm.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								frontend/src/components/NameEditForm.vue
									
									
									
									
									
										Normal 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> | ||||||
| @@ -1,43 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <div class="container"> |  | ||||||
|     <div class="view active"> |  | ||||||
|       <h1>🚫 Forbidden</h1> |  | ||||||
|       <div v-if="authStore.userInfo?.authenticated" class="user-header"> |  | ||||||
|         <span class="user-emoji" aria-hidden="true">{{ userEmoji }}</span> |  | ||||||
|         <span class="user-name">{{ displayName }}</span> |  | ||||||
|       </div> |  | ||||||
|       <p>You lack the permissions required for this page.</p> |  | ||||||
|       <div class="actions"> |  | ||||||
|         <button class="btn-secondary" @click="back">Back</button> |  | ||||||
|         <button class="btn-primary" @click="goAuth">Account</button> |  | ||||||
|         <button class="btn-danger" @click="logout">Logout</button> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
| <script setup> |  | ||||||
| import { useAuthStore } from '@/stores/auth' |  | ||||||
|  |  | ||||||
| const authStore = useAuthStore() |  | ||||||
|  |  | ||||||
| const userEmoji = '👤' // Placeholder / could be extended later if backend provides one |  | ||||||
| const displayName = authStore.userInfo?.user?.user_name || 'User' |  | ||||||
|  |  | ||||||
| function goAuth() { |  | ||||||
|   location.href = '/auth/' |  | ||||||
| } |  | ||||||
| function back() { |  | ||||||
|   if (history.length > 1) history.back() |  | ||||||
|   else authStore.currentView = 'login' |  | ||||||
| } |  | ||||||
| async function logout() { |  | ||||||
|   await authStore.logout() |  | ||||||
| } |  | ||||||
| </script> |  | ||||||
| <style scoped> |  | ||||||
| .user-header { display:flex; align-items:center; gap:.5rem; font-size:1.1rem; margin-bottom:.75rem; } |  | ||||||
| .user-emoji { font-size:1.5rem; line-height:1; } |  | ||||||
| .user-name { font-weight:600; } |  | ||||||
| .actions { margin-top:1.5rem; display:flex; gap:.5rem; flex-wrap:nowrap; } |  | ||||||
| .hint { font-size:.9rem; opacity:.85; } |  | ||||||
| </style> |  | ||||||
| @@ -1,8 +1,13 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="container"> |   <section class="view-root" data-view="profile"> | ||||||
|     <div class="view active"> |     <div class="view-content"> | ||||||
|  |       <header class="view-header"> | ||||||
|         <h1>👋 Welcome!</h1> |         <h1>👋 Welcome!</h1> | ||||||
|       <Breadcrumbs :entries="[{ label: 'Auth', href: '/auth/' }, ...(isAdmin ? [{ label: 'Admin', href: '/auth/admin/' }] : [])]" /> |         <Breadcrumbs :entries="breadcrumbEntries" /> | ||||||
|  |         <p class="view-lede">Manage your account details and passkeys.</p> | ||||||
|  |       </header> | ||||||
|  |  | ||||||
|  |       <section class="section-block"> | ||||||
|         <UserBasicInfo |         <UserBasicInfo | ||||||
|           v-if="authStore.userInfo?.user" |           v-if="authStore.userInfo?.user" | ||||||
|           :name="authStore.userInfo.user.user_name" |           :name="authStore.userInfo.user.user_name" | ||||||
| @@ -12,112 +17,106 @@ | |||||||
|           :loading="authStore.isLoading" |           :loading="authStore.isLoading" | ||||||
|           update-endpoint="/auth/api/user/display-name" |           update-endpoint="/auth/api/user/display-name" | ||||||
|           @saved="authStore.loadUserInfo()" |           @saved="authStore.loadUserInfo()" | ||||||
|  |           @edit-name="openNameDialog" | ||||||
|  |         /> | ||||||
|  |       </section> | ||||||
|  |  | ||||||
|  |       <section class="section-block"> | ||||||
|  |         <div class="section-header"> | ||||||
|  |           <h2>Your Passkeys</h2> | ||||||
|  |           <p class="section-description">Keep at least one trusted passkey so you can always sign in.</p> | ||||||
|  |         </div> | ||||||
|  |         <div class="section-body"> | ||||||
|  |           <CredentialList | ||||||
|  |             :credentials="authStore.userInfo?.credentials || []" | ||||||
|  |             :aaguid-info="authStore.userInfo?.aaguid_info || {}" | ||||||
|  |             :loading="authStore.isLoading" | ||||||
|  |             allow-delete | ||||||
|  |             @delete="handleDelete" | ||||||
|  |           /> | ||||||
|  |           <div class="button-row"> | ||||||
|  |             <button @click="addNewCredential" class="btn-primary">Add New Passkey</button> | ||||||
|  |             <button @click="showRegLink = true" class="btn-secondary">Add Another Device</button> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </section> | ||||||
|  |  | ||||||
|  |       <SessionList | ||||||
|  |         :sessions="sessions" | ||||||
|  |         :terminating-sessions="terminatingSessions" | ||||||
|  |         @terminate="terminateSession" | ||||||
|  |         section-description="Review where you're signed in and end any sessions you no longer recognize." | ||||||
|       /> |       /> | ||||||
|  |  | ||||||
|       <h2>Your Passkeys</h2> |       <Modal v-if="showNameDialog" @close="showNameDialog = false"> | ||||||
|       <div class="credential-list"> |         <h3>Edit Display Name</h3> | ||||||
|         <div v-if="authStore.isLoading"> |         <form @submit.prevent="saveName" class="modal-form"> | ||||||
|           <p>Loading credentials...</p> |           <NameEditForm | ||||||
|         </div> |             label="Display Name" | ||||||
|         <div v-else-if="authStore.userInfo?.credentials?.length === 0"> |             v-model="newName" | ||||||
|           <p>No passkeys found.</p> |             :busy="saving" | ||||||
|         </div> |             @cancel="showNameDialog = false" | ||||||
|         <div v-else> |           /> | ||||||
|           <div |         </form> | ||||||
|             v-for="credential in authStore.userInfo?.credentials || []" |       </Modal> | ||||||
|             :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> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|  |  | ||||||
|       <div class="button-group" style="display: flex; gap: 10px;"> |       <section class="section-block"> | ||||||
|         <button @click="addNewCredential" class="btn-primary"> |         <div class="button-row logout-row" :class="{ single: !hasMultipleSessions }"> | ||||||
|           Add New Passkey |           <button | ||||||
|         </button> |             type="button" | ||||||
|         <button @click="authStore.currentView = 'device-link'" class="btn-primary"> |             class="btn-secondary" | ||||||
|           Add Another Device |             @click="history.back()" | ||||||
|         </button> |           > | ||||||
|       </div> |             Back | ||||||
|       <button @click="logout" class="btn-danger" style="width: 100%;"> |  | ||||||
|         Logout |  | ||||||
|           </button> |           </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> |         </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> | </template> | ||||||
|  |  | ||||||
| <script setup> | <script setup> | ||||||
| import { ref, onMounted, onUnmounted, computed } from 'vue' | import { ref, onMounted, onUnmounted, computed, watch } from 'vue' | ||||||
| import Breadcrumbs from '@/components/Breadcrumbs.vue' | import Breadcrumbs from '@/components/Breadcrumbs.vue' | ||||||
| import { useAuthStore } from '@/stores/auth' | import CredentialList from '@/components/CredentialList.vue' | ||||||
| import { formatDate } from '@/utils/helpers' |  | ||||||
| import passkey from '@/utils/passkey' |  | ||||||
| import UserBasicInfo from '@/components/UserBasicInfo.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 authStore = useAuthStore() | ||||||
| const updateInterval = ref(null) | 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(() => { | onMounted(() => { | ||||||
|   updateInterval.value = setInterval(() => { |   updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000) | ||||||
|     // Trigger Vue reactivity to update formatDate fields |  | ||||||
|     if (authStore.userInfo) { |  | ||||||
|       authStore.userInfo = { ...authStore.userInfo } |  | ||||||
|     } |  | ||||||
|   }, 60000) // Update every minute |  | ||||||
| }) | }) | ||||||
|  |  | ||||||
| onUnmounted(() => { | onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) }) | ||||||
|   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 |  | ||||||
| } |  | ||||||
|  |  | ||||||
| const addNewCredential = async () => { | const addNewCredential = async () => { | ||||||
|   try { |   try { | ||||||
| @@ -129,42 +128,72 @@ const addNewCredential = async () => { | |||||||
|   } catch (error) { |   } catch (error) { | ||||||
|     console.error('Failed to add new passkey:', error) |     console.error('Failed to add new passkey:', error) | ||||||
|     authStore.showMessage(error.message, 'error') |     authStore.showMessage(error.message, 'error') | ||||||
|   } finally { |   } finally { authStore.isLoading = false } | ||||||
|     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 |   if (!confirm('Are you sure you want to delete this passkey?')) return | ||||||
|   try { |   try { | ||||||
|     await authStore.deleteCredential(credentialId) |     await authStore.deleteCredential(credentialId) | ||||||
|     authStore.showMessage('Passkey deleted successfully!', 'success', 3000) |     authStore.showMessage('Passkey deleted successfully!', 'success', 3000) | ||||||
|   } catch (error) { |   } catch (error) { authStore.showMessage(`Failed to delete passkey: ${error.message}`, '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 () => { | const logoutEverywhere = async () => { await authStore.logoutEverywhere() } | ||||||
|   await authStore.logout() | 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 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> | </script> | ||||||
|  |  | ||||||
| <style scoped> | <style scoped> | ||||||
| /* Removed inline user info styles; now provided by UserBasicInfo component */ | .view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; } | ||||||
| .admin-link { | .section-header { display: flex; flex-direction: column; gap: 0.4rem; } | ||||||
|   font-size: 0.6em; | .section-description { margin: 0; color: var(--color-text-muted); } | ||||||
|   margin-left: 0.75rem; | .empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; } | ||||||
|   text-decoration: none; | .logout-button { align-self: flex-start; } | ||||||
|   background: var(--color-background-soft, #eee); | .logout-row { gap: 1rem; } | ||||||
|   padding: 0.2em 0.6em; | .logout-row.single { justify-content: flex-start; } | ||||||
|   border-radius: 999px; | .logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; } | ||||||
|   border: 1px solid var(--color-border, #ccc); | @media (max-width: 720px) { .logout-button { width: 100%; } } | ||||||
|   vertical-align: middle; |  | ||||||
|   line-height: 1.2; |  | ||||||
| } |  | ||||||
| .admin-link:hover { |  | ||||||
|   background: var(--color-background-mute, #ddd); |  | ||||||
| } |  | ||||||
| </style> | </style> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,8 +1,10 @@ | |||||||
| <template> | <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 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;"> |       <div class="reg-header-row"> | ||||||
|         <h2 id="regTitle" style="margin:0; font-size:1.25rem;">📱 Device Registration Link</h2> |         <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> |         <button class="icon-btn" @click="$emit('close')" aria-label="Close">❌</button> | ||||||
|       </div> |       </div> | ||||||
|       <div class="device-link-section"> |       <div class="device-link-section"> | ||||||
| @@ -14,28 +16,62 @@ | |||||||
|           <div v-else> |           <div v-else> | ||||||
|             <em>Generating link...</em> |             <em>Generating link...</em> | ||||||
|           </div> |           </div> | ||||||
|           <p> |           <p class="reg-help"> | ||||||
|             <strong>Scan and visit the URL on another device.</strong><br> |             <span v-if="userName">The user should open this link on the device where they want to register.</span> | ||||||
|             <small>⚠️ Expires in 24 hours and one-time use.</small> |             <span v-else>Open or scan this link on the device you wish to register to your account.</span> | ||||||
|  |             <br><small>{{ expirationMessage }}</small> | ||||||
|           </p> |           </p> | ||||||
|           <div v-if="expires" style="font-size:12px; margin-top:6px;">Expires: {{ new Date(expires).toLocaleString() }}</div> |  | ||||||
|         </div> |         </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-secondary" @click="$emit('close')">Close</button> | ||||||
|         <button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button> |         <button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </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> | </template> | ||||||
|  |  | ||||||
| <script setup> | <script setup> | ||||||
| import { ref, onMounted, watch, computed, nextTick } from 'vue' | import { ref, onMounted, watch, computed, nextTick } from 'vue' | ||||||
| import QRCode from 'qrcode/lib/browser' | import QRCode from 'qrcode/lib/browser' | ||||||
|  | import { formatDate } from '@/utils/helpers' | ||||||
|  |  | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|   endpoint: { type: String, required: true }, // POST endpoint returning {url, expires} |   endpoint: { type: String, required: true }, | ||||||
|   autoCopy: { type: Boolean, default: 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']) | const emit = defineEmits(['close','generated','copied']) | ||||||
| @@ -46,6 +82,11 @@ const qrCanvas = ref(null) | |||||||
|  |  | ||||||
| const displayUrl = computed(() => url.value ? url.value.replace(/^[^:]+:\/\//,'') : '') | 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() { | async function fetchLink() { | ||||||
|   try { |   try { | ||||||
|     const res = await fetch(props.endpoint, { method: 'POST' }) |     const res = await fetch(props.endpoint, { method: 'POST' }) | ||||||
| @@ -73,15 +114,35 @@ async function drawQR() { | |||||||
|  |  | ||||||
| async function copy() { | async function copy() { | ||||||
|   if (!url.value) return |   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) | onMounted(fetchLink) | ||||||
| watch(url, () => drawQR(), { flush: 'post' }) | watch(url, () => drawQR(), { flush: 'post' }) | ||||||
|  |  | ||||||
| </script> | </script> | ||||||
| <style scoped> | <style scoped> | ||||||
| .icon-btn { background:none; border:none; cursor:pointer; font-size:1rem; opacity:.6; } | .icon-btn { background:none; border:none; cursor:pointer; font-size:1rem; opacity:.6; } | ||||||
| .icon-btn:hover { opacity:1; } | .icon-btn:hover { opacity:1; } | ||||||
| /* Minimal extra styling; main look comes from global styles */ | /* Minimal extra styling; main look comes from global styles */ | ||||||
| .qr-link { text-decoration:none; color:inherit; } | .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> | </style> | ||||||
|   | |||||||
| @@ -1,55 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <div class="container"> |  | ||||||
|     <div class="view active"> |  | ||||||
|       <h1>🔑 Add New Credential</h1> |  | ||||||
|       <label class="name-edit"> |  | ||||||
|         <span>👤 Name:</span> |  | ||||||
|         <input |  | ||||||
|           type="text" |  | ||||||
|           v-model="user_name" |  | ||||||
|           :placeholder="authStore.userInfo?.user?.user_name || 'Your name'" |  | ||||||
|           :disabled="authStore.isLoading" |  | ||||||
|           maxlength="64" |  | ||||||
|           @keyup.enter="register" |  | ||||||
|         /> |  | ||||||
|       </label> |  | ||||||
|       <p>Proceed to complete {{authStore.userInfo?.session_type}}:</p> |  | ||||||
|       <button |  | ||||||
|         class="btn-primary" |  | ||||||
|         :disabled="authStore.isLoading" |  | ||||||
|         @click="register" |  | ||||||
|       > |  | ||||||
|         {{ authStore.isLoading ? 'Registering...' : 'Register Passkey' }} |  | ||||||
|       </button> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <script setup> |  | ||||||
| import { useAuthStore } from '@/stores/auth' |  | ||||||
| import passkey from '@/utils/passkey' |  | ||||||
| import { ref } from 'vue' |  | ||||||
|  |  | ||||||
| const authStore = useAuthStore() |  | ||||||
| const user_name = ref('') // intentionally blank; original shown via placeholder |  | ||||||
|  |  | ||||||
| async function register() { |  | ||||||
|   authStore.isLoading = true |  | ||||||
|   authStore.showMessage('Starting registration...', 'info') |  | ||||||
|  |  | ||||||
|   try { |  | ||||||
|   const result = await passkey.register(authStore.resetToken, user_name.value) |  | ||||||
|   console.log("Result", result) |  | ||||||
|   await authStore.setSessionCookie(result.session_token) |  | ||||||
|   // resetToken cleared by setSessionCookie; ensure again |  | ||||||
|   authStore.resetToken = null |  | ||||||
|   authStore.showMessage('Passkey registered successfully!', 'success', 2000) |  | ||||||
|   await authStore.loadUserInfo() |  | ||||||
|   authStore.selectView() |  | ||||||
|   } catch (error) { |  | ||||||
|     authStore.showMessage(`Registration failed: ${error.message}`, 'error') |  | ||||||
|   } finally { |  | ||||||
|     authStore.isLoading = false |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </script> |  | ||||||
							
								
								
									
										73
									
								
								frontend/src/components/SessionList.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								frontend/src/components/SessionList.vue
									
									
									
									
									
										Normal 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> | ||||||
| @@ -2,21 +2,9 @@ | |||||||
|   <div v-if="userLoaded" class="user-info"> |   <div v-if="userLoaded" class="user-info"> | ||||||
|     <h3 class="user-name-heading"> |     <h3 class="user-name-heading"> | ||||||
|       <span class="icon">👤</span> |       <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> |         <span class="display-name" :title="name">{{ name }}</span> | ||||||
|         <button v-if="canEdit && updateEndpoint" class="mini-btn" @click="startEdit" title="Edit name">✏️</button> |         <button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('editName')" 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> |  | ||||||
|       </span> |       </span> | ||||||
|     </h3> |     </h3> | ||||||
|     <div v-if="orgDisplayName || roleName" class="org-role-sub"> |     <div v-if="orgDisplayName || roleName" class="org-role-sub"> | ||||||
| @@ -49,53 +37,29 @@ const props = defineProps({ | |||||||
|   roleName: { type: String, default: '' } |   roleName: { type: String, default: '' } | ||||||
| }) | }) | ||||||
|  |  | ||||||
| const emit = defineEmits(['saved']) | const emit = defineEmits(['saved', 'editName']) | ||||||
| const authStore = useAuthStore() | const authStore = useAuthStore() | ||||||
|  |  | ||||||
| const editingName = ref(false) |  | ||||||
| const newName = ref('') |  | ||||||
| const busy = ref(false) |  | ||||||
| const userLoaded = computed(() => !!props.name) | 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> | </script> | ||||||
|  |  | ||||||
| <style scoped> | <style scoped> | ||||||
| .user-info { display: grid; grid-template-columns: auto 1fr; gap: 10px; } | .user-info { display: grid; grid-template-columns: auto 1fr; gap: 10px; } | ||||||
| .user-info h3 { grid-column: span 2; } | .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-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; } | .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:.6rem; color:#555; line-height:1.1; } | .role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; } | ||||||
| .user-info span { text-align: left; } | .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-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 { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; } | ||||||
| .user-name-row.editing { flex: 1 1 auto; } | .user-name-row.editing { flex: 1 1 auto; } | ||||||
| .icon { flex: 0 0 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; } | .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; } | .user-name-heading .name-input { width: auto; } | ||||||
| .name-input:focus { outline: 2px solid #667eea55; border-color: #667eea; } | .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: #eef5fa; border: 1px solid #b7d2e3; border-radius: 6px; cursor: pointer; transition: background 0.2s, transform 0.15s; } | .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: #dcecf6; } | .mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); } | ||||||
| .mini-btn:active:not(:disabled) { transform: translateY(1px); } | .mini-btn:active:not(:disabled) { transform: translateY(1px); } | ||||||
| .mini-btn:disabled { opacity: 0.5; cursor: not-allowed; } | .mini-btn:disabled { opacity: 0.5; cursor: not-allowed; } | ||||||
| @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%; } } | @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%; } } | ||||||
|   | |||||||
							
								
								
									
										138
									
								
								frontend/src/host/HostApp.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								frontend/src/host/HostApp.vue
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										11
									
								
								frontend/src/host/main.js
									
									
									
									
									
										Normal 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') | ||||||
							
								
								
									
										249
									
								
								frontend/src/reset/ResetApp.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										249
									
								
								frontend/src/reset/ResetApp.vue
									
									
									
									
									
										Normal 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> | ||||||
							
								
								
									
										5
									
								
								frontend/src/reset/main.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								frontend/src/reset/main.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | import { createApp } from 'vue' | ||||||
|  | import ResetApp from './ResetApp.vue' | ||||||
|  | import '@/assets/style.css' | ||||||
|  |  | ||||||
|  | createApp(ResetApp).mount('#app') | ||||||
							
								
								
									
										176
									
								
								frontend/src/restricted/RestrictedApp.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								frontend/src/restricted/RestrictedApp.vue
									
									
									
									
									
										Normal 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> | ||||||
							
								
								
									
										5
									
								
								frontend/src/restricted/main.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								frontend/src/restricted/main.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | import { createApp } from 'vue' | ||||||
|  | import RestrictedApp from './RestrictedApp.vue' | ||||||
|  | import '@/assets/style.css' | ||||||
|  |  | ||||||
|  | createApp(RestrictedApp).mount('#app') | ||||||
| @@ -1,14 +1,15 @@ | |||||||
| import { defineStore } from 'pinia' | import { defineStore } from 'pinia' | ||||||
| import { register, authenticate } from '@/utils/passkey' | import { register, authenticate } from '@/utils/passkey' | ||||||
|  | import { getSettings } from '@/utils/settings' | ||||||
|  |  | ||||||
| export const useAuthStore = defineStore('auth', { | export const useAuthStore = defineStore('auth', { | ||||||
|   state: () => ({ |   state: () => ({ | ||||||
|     // Auth State |     // Auth State | ||||||
|     userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated} |     userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info} | ||||||
|     settings: null, // Server provided settings (/auth/settings) |  | ||||||
|     isLoading: false, |     isLoading: false, | ||||||
|     resetToken: null, // transient reset token |  | ||||||
|     restrictedMode: false, // Anywhere other than /auth/: restrict to login or permission denied |     // Settings | ||||||
|  |     settings: null, | ||||||
|  |  | ||||||
|     // UI State |     // UI State | ||||||
|     currentView: 'login', |     currentView: 'login', | ||||||
| @@ -18,7 +19,12 @@ export const useAuthStore = defineStore('auth', { | |||||||
|       show: false |       show: false | ||||||
|     }, |     }, | ||||||
|   }), |   }), | ||||||
|  |   getters: { | ||||||
|  |   }, | ||||||
|   actions: { |   actions: { | ||||||
|  |     setLoading(flag) { | ||||||
|  |       this.isLoading = !!flag | ||||||
|  |     }, | ||||||
|     showMessage(message, type = 'info', duration = 3000) { |     showMessage(message, type = 'info', duration = 3000) { | ||||||
|       this.status = { |       this.status = { | ||||||
|         message, |         message, | ||||||
| @@ -40,9 +46,6 @@ export const useAuthStore = defineStore('auth', { | |||||||
|       if (result.detail) { |       if (result.detail) { | ||||||
|         throw new Error(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 |       return result | ||||||
|     }, |     }, | ||||||
|     async register() { |     async register() { | ||||||
| @@ -51,6 +54,7 @@ export const useAuthStore = defineStore('auth', { | |||||||
|         const result = await register() |         const result = await register() | ||||||
|         await this.setSessionCookie(result.session_token) |         await this.setSessionCookie(result.session_token) | ||||||
|         await this.loadUserInfo() |         await this.loadUserInfo() | ||||||
|  |         this.selectView() | ||||||
|         return result |         return result | ||||||
|       } finally { |       } finally { | ||||||
|         this.isLoading = false |         this.isLoading = false | ||||||
| @@ -63,6 +67,7 @@ export const useAuthStore = defineStore('auth', { | |||||||
|  |  | ||||||
|         await this.setSessionCookie(result.session_token) |         await this.setSessionCookie(result.session_token) | ||||||
|         await this.loadUserInfo() |         await this.loadUserInfo() | ||||||
|  |         this.selectView() | ||||||
|  |  | ||||||
|         return result |         return result | ||||||
|       } finally { |       } finally { | ||||||
| @@ -70,25 +75,14 @@ export const useAuthStore = defineStore('auth', { | |||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     selectView() { |     selectView() { | ||||||
|       if (this.restrictedMode) { |  | ||||||
|         // In restricted mode only allow login or show permission denied if already authenticated |  | ||||||
|       if (!this.userInfo) this.currentView = 'login' |       if (!this.userInfo) this.currentView = 'login' | ||||||
|         else if (this.userInfo.authenticated) this.currentView = 'permission-denied' |       else this.currentView = 'profile' | ||||||
|         else this.currentView = 'login' // do not expose reset/registration flows outside /auth/ |  | ||||||
|         return |  | ||||||
|       } |  | ||||||
|       if (!this.userInfo) this.currentView = 'login' |  | ||||||
|       else if (this.userInfo.authenticated) this.currentView = 'profile' |  | ||||||
|       else this.currentView = 'reset' |  | ||||||
|     }, |     }, | ||||||
|     setRestrictedMode(flag) { |     async loadSettings() { | ||||||
|       this.restrictedMode = !!flag |       this.settings = await getSettings() | ||||||
|     }, |     }, | ||||||
|     async loadUserInfo() { |     async loadUserInfo() { | ||||||
|       const headers = {} |       const response = await fetch('/auth/api/user-info', { method: 'POST' }) | ||||||
|       // Reset tokens are only passed via query param now, not Authorization header |  | ||||||
|   const url = this.resetToken ? `/auth/api/user-info?reset=${encodeURIComponent(this.resetToken)}` : '/auth/api/user-info' |  | ||||||
|       const response = await fetch(url, { method: 'POST', headers }) |  | ||||||
|       let result = null |       let result = null | ||||||
|       try { |       try { | ||||||
|         result = await response.json() |         result = await response.json() | ||||||
| @@ -107,29 +101,51 @@ export const useAuthStore = defineStore('auth', { | |||||||
|       this.userInfo = result |       this.userInfo = result | ||||||
|       console.log('User info loaded:', 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) { |     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() |       const result = await response.json() | ||||||
|       if (result.detail) throw new Error(`Server: ${result.detail}`) |       if (result.detail) throw new Error(`Server: ${result.detail}`) | ||||||
|  |  | ||||||
|       await this.loadUserInfo() |       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() { |     async logout() { | ||||||
|       try { |       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() |         sessionStorage.clear() | ||||||
|         location.reload() |         location.reload() | ||||||
|       } catch (error) { |       } catch (error) { | ||||||
| @@ -137,5 +153,25 @@ export const useAuthStore = defineStore('auth', { | |||||||
|         this.showMessage(error.message, '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') | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|   } |   } | ||||||
| }) | }) | ||||||
|   | |||||||
| @@ -5,16 +5,18 @@ export function formatDate(dateString) { | |||||||
|  |  | ||||||
|   const date = new Date(dateString) |   const date = new Date(dateString) | ||||||
|   const now = new Date() |   const now = new Date() | ||||||
|   const diffMs = now - date |   const diffMs = date - now  // Changed to date - now for future/past | ||||||
|   const diffMinutes = Math.floor(diffMs / (1000 * 60)) |   const isFuture = diffMs > 0 | ||||||
|   const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) |   const absDiffMs = Math.abs(diffMs) | ||||||
|   const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) |   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 (absDiffMs < 1000 * 60) return 'Now' | ||||||
|   if (diffMinutes === 0) return 'Just now' |   if (diffMinutes <= 60) return isFuture ? `In ${diffMinutes} minute${diffMinutes === 1 ? '' : 's'}` : diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago` | ||||||
|   if (diffMinutes < 60) return 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 (diffHours < 24) return 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 diffDays === 1 ? 'a day ago' : `${diffDays} days ago` |   return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) | ||||||
| } | } | ||||||
|  |  | ||||||
| export function getCookie(name) { | export function getCookie(name) { | ||||||
|   | |||||||
| @@ -1,13 +1,21 @@ | |||||||
| import { startRegistration, startAuthentication } from '@simplewebauthn/browser' | import { startRegistration, startAuthentication } from '@simplewebauthn/browser' | ||||||
| import aWebSocket from '@/utils/awaitable-websocket' | import aWebSocket from '@/utils/awaitable-websocket' | ||||||
|  | import { getSettings } from '@/utils/settings' | ||||||
|  |  | ||||||
|  | // 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) { | export async function register(resetToken = null, displayName = null) { | ||||||
|   let params = [] |   let params = [] | ||||||
|   if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`) |   if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`) | ||||||
|   if (displayName) params.push(`name=${encodeURIComponent(displayName)}`) |   if (displayName) params.push(`name=${encodeURIComponent(displayName)}`) | ||||||
|   const qs = params.length ? `?${params.join('&')}` : '' |   const qs = params.length ? `?${params.join('&')}` : '' | ||||||
|   const url = `/auth/ws/register${qs}` |   const ws = await aWebSocket(await makeUrl(`/auth/ws/register${qs}`)) | ||||||
|   const ws = await aWebSocket(url) |  | ||||||
|   try { |   try { | ||||||
|     const optionsJSON = await ws.receive_json() |     const optionsJSON = await ws.receive_json() | ||||||
|     const registrationResponse = await startRegistration({ optionsJSON }) |     const registrationResponse = await startRegistration({ optionsJSON }) | ||||||
| @@ -23,7 +31,7 @@ export async function register(resetToken = null, displayName = null) { | |||||||
| } | } | ||||||
|  |  | ||||||
| export async function authenticate() { | export async function authenticate() { | ||||||
|   const ws = await aWebSocket('/auth/ws/authenticate') |   const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate')) | ||||||
|   try { |   try { | ||||||
|     const optionsJSON = await ws.receive_json() |     const optionsJSON = await ws.receive_json() | ||||||
|     const authResponse = await startAuthentication({ optionsJSON }) |     const authResponse = await startAuthentication({ optionsJSON }) | ||||||
|   | |||||||
							
								
								
									
										29
									
								
								frontend/src/utils/settings.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								frontend/src/utils/settings.js
									
									
									
									
									
										Normal 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 | ||||||
|  | } | ||||||
| @@ -33,8 +33,14 @@ export default defineConfig(({ command, mode }) => ({ | |||||||
|           // Bypass only root SPA entrypoints + static assets so Vite serves them for HMR. |           // 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. |           // Admin API endpoints (e.g., /auth/admin/orgs) must still hit backend. | ||||||
|           if (url === '/auth/' || url === '/auth') return '/' |           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 === '/auth/admin' || url === '/auth/admin/') return '/admin/' | ||||||
|           if (url.startsWith('/auth/assets/')) return url.replace(/^\/auth/, '') |           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. |           // Everything else (including /auth/admin/* APIs) should proxy. | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
| @@ -47,7 +53,10 @@ export default defineConfig(({ command, mode }) => ({ | |||||||
|     rollupOptions: { |     rollupOptions: { | ||||||
|       input: { |       input: { | ||||||
|         index: resolve(__dirname, 'index.html'), |         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: {} |       output: {} | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -8,61 +8,115 @@ independent of any web framework: | |||||||
| - Credential management | - Credential management | ||||||
| """ | """ | ||||||
|  |  | ||||||
| from datetime import datetime, timedelta | from datetime import datetime, timezone | ||||||
| from uuid import UUID | from uuid import UUID | ||||||
|  |  | ||||||
| from .db import Session | from .config import SESSION_LIFETIME | ||||||
| from .globals import db | from .db import ResetToken, Session | ||||||
|  | from .globals import db, passkey | ||||||
|  | from .util import hostutil | ||||||
| from .util.tokens import create_token, reset_key, session_key | from .util.tokens import create_token, reset_key, session_key | ||||||
|  |  | ||||||
| EXPIRES = timedelta(hours=24) | EXPIRES = SESSION_LIFETIME | ||||||
|  |  | ||||||
|  |  | ||||||
| def expires() -> datetime: | 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.""" |     """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() |     token = create_token() | ||||||
|  |     now = datetime.now(timezone.utc) | ||||||
|     await db.instance.create_session( |     await db.instance.create_session( | ||||||
|         user_uuid=user_uuid, |         user_uuid=user_uuid, | ||||||
|         credential_uuid=credential_uuid, |         credential_uuid=credential_uuid, | ||||||
|         key=session_key(token), |         key=session_key(token), | ||||||
|         expires=datetime.now() + EXPIRES, |         host=normalized_host, | ||||||
|         info=info, |         ip=ip, | ||||||
|  |         user_agent=user_agent, | ||||||
|  |         renewed=now, | ||||||
|     ) |     ) | ||||||
|     return token |     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).""" |     """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)) |     record = await db.instance.get_reset_token(reset_key(token)) | ||||||
|     if not session: |     if not record: | ||||||
|         raise ValueError("Invalid or expired session token") |         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.""" |     """Validate a session token and return session data if valid.""" | ||||||
|     session = await db.instance.get_session(session_key(token)) |     session = await db.instance.get_session(session_key(token)) | ||||||
|     if not session: |     if not session: | ||||||
|         raise ValueError("Invalid or expired session token") |         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 |     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.""" |     """Refresh a session extending its expiry.""" | ||||||
|     # Get the current session |     session_record = await db.instance.get_session(session_key(token)) | ||||||
|     s = await db.instance.update_session( |     if not session_record: | ||||||
|         session_key(token), datetime.now() + EXPIRES, {} |         raise ValueError("Session not found or expired") | ||||||
|  |     updated = await db.instance.update_session( | ||||||
|  |         session_key(token), | ||||||
|  |         ip=ip, | ||||||
|  |         user_agent=user_agent, | ||||||
|  |         renewed=datetime.now(timezone.utc), | ||||||
|     ) |     ) | ||||||
|  |     if not updated: | ||||||
|     if not s: |  | ||||||
|         raise ValueError("Session not found or expired") |         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.""" |     """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) |     await db.instance.delete_credential(credential_uuid, s.user_uuid) | ||||||
|   | |||||||
| @@ -8,13 +8,13 @@ generating a reset link for initial admin setup. | |||||||
|  |  | ||||||
| import asyncio | import asyncio | ||||||
| import logging | import logging | ||||||
| from datetime import datetime | from datetime import datetime, timezone | ||||||
|  |  | ||||||
| import uuid7 | import uuid7 | ||||||
|  |  | ||||||
| from . import authsession, globals | from . import authsession, globals | ||||||
| from .db import Org, Permission, Role, User | from .db import Org, Permission, Role, User | ||||||
| from .util import passphrase, tokens | from .util import hostutil, passphrase, tokens | ||||||
|  |  | ||||||
|  |  | ||||||
| def _init_logger() -> logging.Logger: | 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: | 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.""" |     """Create an admin reset link and log it with the provided message.""" | ||||||
|     token = passphrase.generate() |     token = passphrase.generate() | ||||||
|     await globals.db.instance.create_session( |     expiry = authsession.reset_expires() | ||||||
|  |     await globals.db.instance.create_reset_token( | ||||||
|         user_uuid=user_uuid, |         user_uuid=user_uuid, | ||||||
|         key=tokens.reset_key(token), |         key=tokens.reset_key(token), | ||||||
|         expires=authsession.expires(), |         expiry=expiry, | ||||||
|         info={"type": session_type}, |         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) |     logger.info(ADMIN_RESET_MESSAGE, message, reset_link) | ||||||
|     return reset_link |     return reset_link | ||||||
|  |  | ||||||
| @@ -90,7 +91,7 @@ async def bootstrap_system( | |||||||
|         uuid=uuid7.create(), |         uuid=uuid7.create(), | ||||||
|         display_name=user_name or "Admin", |         display_name=user_name or "Admin", | ||||||
|         role_uuid=role.uuid, |         role_uuid=role.uuid, | ||||||
|         created_at=datetime.now(), |         created_at=datetime.now(timezone.utc), | ||||||
|         visits=0, |         visits=0, | ||||||
|     ) |     ) | ||||||
|     await globals.db.instance.create_user(user) |     await globals.db.instance.create_user(user) | ||||||
|   | |||||||
							
								
								
									
										7
									
								
								passkey/config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								passkey/config.py
									
									
									
									
									
										Normal 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) | ||||||
| @@ -63,9 +63,27 @@ class Credential: | |||||||
| class Session: | class Session: | ||||||
|     key: bytes |     key: bytes | ||||||
|     user_uuid: UUID |     user_uuid: UUID | ||||||
|     expires: datetime |     credential_uuid: UUID | ||||||
|     info: dict |     host: str | ||||||
|     credential_uuid: UUID | None = None |     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 | @dataclass | ||||||
| @@ -146,9 +164,11 @@ class DatabaseInterface(ABC): | |||||||
|         self, |         self, | ||||||
|         user_uuid: UUID, |         user_uuid: UUID, | ||||||
|         key: bytes, |         key: bytes, | ||||||
|         expires: datetime, |         credential_uuid: UUID, | ||||||
|         info: dict, |         host: str, | ||||||
|         credential_uuid: UUID | None = None, |         ip: str, | ||||||
|  |         user_agent: str, | ||||||
|  |         renewed: datetime, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         """Create a new session.""" |         """Create a new session.""" | ||||||
|  |  | ||||||
| @@ -162,14 +182,50 @@ class DatabaseInterface(ABC): | |||||||
|  |  | ||||||
|     @abstractmethod |     @abstractmethod | ||||||
|     async def update_session( |     async def update_session( | ||||||
|         self, key: bytes, expires: datetime, info: dict |         self, | ||||||
|  |         key: bytes, | ||||||
|  |         *, | ||||||
|  |         ip: str, | ||||||
|  |         user_agent: str, | ||||||
|  |         renewed: datetime, | ||||||
|     ) -> Session | None: |     ) -> 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 |     @abstractmethod | ||||||
|     async def cleanup(self) -> None: |     async def cleanup(self) -> None: | ||||||
|         """Called periodically to clean up expired records.""" |         """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 |     # Organization operations | ||||||
|     @abstractmethod |     @abstractmethod | ||||||
|     async def create_organization(self, org: Org) -> None: |     async def create_organization(self, org: Org) -> None: | ||||||
| @@ -315,7 +371,9 @@ class DatabaseInterface(ABC): | |||||||
|         """Create a new user and their first credential in a transaction.""" |         """Create a new user and their first credential in a transaction.""" | ||||||
|  |  | ||||||
|     @abstractmethod |     @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.""" |         """Get complete session context including user, organization, role, and permissions.""" | ||||||
|  |  | ||||||
|     # Combined atomic operations |     # Combined atomic operations | ||||||
| @@ -326,15 +384,17 @@ class DatabaseInterface(ABC): | |||||||
|         credential: Credential, |         credential: Credential, | ||||||
|         reset_key: bytes | None, |         reset_key: bytes | None, | ||||||
|         session_key: bytes, |         session_key: bytes, | ||||||
|             session_expires: datetime, |         *, | ||||||
|             session_info: dict, |  | ||||||
|         display_name: str | None = None, |         display_name: str | None = None, | ||||||
|  |         host: str | None = None, | ||||||
|  |         ip: str | None = None, | ||||||
|  |         user_agent: str | None = None, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         """Atomically add a credential and create a session. |         """Atomically add a credential and create a session. | ||||||
|  |  | ||||||
|         Steps (single transaction): |         Steps (single transaction): | ||||||
|             1. Insert credential |             1. Insert credential | ||||||
|                 2. Optionally delete old session (e.g. reset token) if provided |             2. Optionally delete old reset token if provided | ||||||
|             3. Optionally update user's display name |             3. Optionally update user's display name | ||||||
|             4. Insert new session referencing the credential |             4. Insert new session referencing the credential | ||||||
|             5. Update user's last_seen and increment visits (treat as a login) |             5. Update user's last_seen and increment visits (treat as a login) | ||||||
| @@ -345,6 +405,7 @@ __all__ = [ | |||||||
|     "User", |     "User", | ||||||
|     "Credential", |     "Credential", | ||||||
|     "Session", |     "Session", | ||||||
|  |     "ResetToken", | ||||||
|     "SessionContext", |     "SessionContext", | ||||||
|     "Org", |     "Org", | ||||||
|     "Role", |     "Role", | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ for managing users and credentials in a WebAuthn authentication system. | |||||||
| """ | """ | ||||||
|  |  | ||||||
| from contextlib import asynccontextmanager | from contextlib import asynccontextmanager | ||||||
| from datetime import datetime | from datetime import datetime, timezone | ||||||
| from uuid import UUID | from uuid import UUID | ||||||
|  |  | ||||||
| from sqlalchemy import ( | from sqlalchemy import ( | ||||||
| @@ -17,19 +17,23 @@ from sqlalchemy import ( | |||||||
|     String, |     String, | ||||||
|     delete, |     delete, | ||||||
|     event, |     event, | ||||||
|  |     insert, | ||||||
|     select, |     select, | ||||||
|  |     text, | ||||||
|     update, |     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.ext.asyncio import async_sessionmaker, create_async_engine | ||||||
| from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column | ||||||
|  |  | ||||||
|  | from ..config import SESSION_LIFETIME | ||||||
| from ..globals import db | from ..globals import db | ||||||
| from . import ( | from . import ( | ||||||
|     Credential, |     Credential, | ||||||
|     DatabaseInterface, |     DatabaseInterface, | ||||||
|     Org, |     Org, | ||||||
|     Permission, |     Permission, | ||||||
|  |     ResetToken, | ||||||
|     Role, |     Role, | ||||||
|     Session, |     Session, | ||||||
|     SessionContext, |     SessionContext, | ||||||
| @@ -39,6 +43,14 @@ from . import ( | |||||||
| DB_PATH = "sqlite+aiosqlite:///passkey-auth.sqlite" | 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): | async def init(*args, **kwargs): | ||||||
|     db.instance = DB() |     db.instance = DB() | ||||||
|     await db.instance.init_db() |     await db.instance.init_db() | ||||||
| @@ -97,8 +109,12 @@ class UserModel(Base): | |||||||
|     role_uuid: Mapped[bytes] = mapped_column( |     role_uuid: Mapped[bytes] = mapped_column( | ||||||
|         LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False |         LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False | ||||||
|     ) |     ) | ||||||
|     created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) |     created_at: Mapped[datetime] = mapped_column( | ||||||
|     last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) |         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) |     visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0) | ||||||
|  |  | ||||||
|     def as_dataclass(self) -> User: |     def as_dataclass(self) -> User: | ||||||
| @@ -106,8 +122,8 @@ class UserModel(Base): | |||||||
|             uuid=UUID(bytes=self.uuid), |             uuid=UUID(bytes=self.uuid), | ||||||
|             display_name=self.display_name, |             display_name=self.display_name, | ||||||
|             role_uuid=UUID(bytes=self.role_uuid), |             role_uuid=UUID(bytes=self.role_uuid), | ||||||
|             created_at=self.created_at, |             created_at=_normalize_dt(self.created_at) or self.created_at, | ||||||
|             last_seen=self.last_seen, |             last_seen=_normalize_dt(self.last_seen) or self.last_seen, | ||||||
|             visits=self.visits, |             visits=self.visits, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @@ -117,7 +133,7 @@ class UserModel(Base): | |||||||
|             uuid=user.uuid.bytes, |             uuid=user.uuid.bytes, | ||||||
|             display_name=user.display_name, |             display_name=user.display_name, | ||||||
|             role_uuid=user.role_uuid.bytes, |             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, |             last_seen=user.last_seen, | ||||||
|             visits=user.visits, |             visits=user.visits, | ||||||
|         ) |         ) | ||||||
| @@ -136,9 +152,29 @@ class CredentialModel(Base): | |||||||
|     aaguid: Mapped[bytes] = mapped_column(LargeBinary(16), nullable=False) |     aaguid: Mapped[bytes] = mapped_column(LargeBinary(16), nullable=False) | ||||||
|     public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False) |     public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False) | ||||||
|     sign_count: Mapped[int] = mapped_column(Integer, nullable=False) |     sign_count: Mapped[int] = mapped_column(Integer, nullable=False) | ||||||
|     created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) |     created_at: Mapped[datetime] = mapped_column( | ||||||
|     last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) |         DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) | ||||||
|     last_verified: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) |     ) | ||||||
|  |     # 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): | class SessionModel(Base): | ||||||
| @@ -146,23 +182,31 @@ class SessionModel(Base): | |||||||
|  |  | ||||||
|     key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) |     key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) | ||||||
|     user_uuid: Mapped[bytes] = mapped_column( |     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( |     credential_uuid: Mapped[bytes] = mapped_column( | ||||||
|         LargeBinary(16), ForeignKey("credentials.uuid", ondelete="CASCADE") |         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): |     def as_dataclass(self): | ||||||
|         return Session( |         return Session( | ||||||
|             key=self.key, |             key=self.key, | ||||||
|             user_uuid=UUID(bytes=self.user_uuid), |             user_uuid=UUID(bytes=self.user_uuid), | ||||||
|             credential_uuid=( |             credential_uuid=UUID(bytes=self.credential_uuid), | ||||||
|                 UUID(bytes=self.credential_uuid) if self.credential_uuid else None |             host=self.host, | ||||||
|             ), |             ip=self.ip, | ||||||
|             expires=self.expires, |             user_agent=self.user_agent, | ||||||
|             info=self.info, |             renewed=_normalize_dt(self.renewed) or self.renewed, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
| @@ -170,9 +214,30 @@ class SessionModel(Base): | |||||||
|         return SessionModel( |         return SessionModel( | ||||||
|             key=session.key, |             key=session.key, | ||||||
|             user_uuid=session.user_uuid.bytes, |             user_uuid=session.user_uuid.bytes, | ||||||
|             credential_uuid=session.credential_uuid and session.credential_uuid.bytes, |             credential_uuid=session.credential_uuid.bytes, | ||||||
|             expires=session.expires, |             host=session.host, | ||||||
|             info=session.info, |             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.""" |         """Initialize database tables.""" | ||||||
|         async with self.engine.begin() as conn: |         async with self.engine.begin() as conn: | ||||||
|             await conn.run_sync(Base.metadata.create_all) |             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 def get_user_by_uuid(self, user_uuid: UUID) -> User: | ||||||
|         async with self.session() as session: |         async with self.session() as session: | ||||||
| @@ -408,9 +525,11 @@ class DB(DatabaseInterface): | |||||||
|         credential: Credential, |         credential: Credential, | ||||||
|         reset_key: bytes | None, |         reset_key: bytes | None, | ||||||
|         session_key: bytes, |         session_key: bytes, | ||||||
|         session_expires: datetime, |         *, | ||||||
|         session_info: dict, |  | ||||||
|         display_name: str | None = None, |         display_name: str | None = None, | ||||||
|  |         host: str | None = None, | ||||||
|  |         ip: str | None = None, | ||||||
|  |         user_agent: str | None = None, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         """Atomic credential + (optional old session delete) + (optional rename) + new session.""" |         """Atomic credential + (optional old session delete) + (optional rename) + new session.""" | ||||||
|         async with self.session() as session: |         async with self.session() as session: | ||||||
| @@ -433,10 +552,10 @@ class DB(DatabaseInterface): | |||||||
|                     last_verified=credential.last_verified, |                     last_verified=credential.last_verified, | ||||||
|                 ) |                 ) | ||||||
|             ) |             ) | ||||||
|             # Delete old session if provided |             # Delete old reset token if provided | ||||||
|             if reset_key: |             if reset_key: | ||||||
|                 await session.execute( |                 await session.execute( | ||||||
|                     delete(SessionModel).where(SessionModel.key == reset_key) |                     delete(ResetTokenModel).where(ResetTokenModel.key == reset_key) | ||||||
|                 ) |                 ) | ||||||
|             # Optional rename |             # Optional rename | ||||||
|             if display_name: |             if display_name: | ||||||
| @@ -451,8 +570,9 @@ class DB(DatabaseInterface): | |||||||
|                     key=session_key, |                     key=session_key, | ||||||
|                     user_uuid=user_uuid.bytes, |                     user_uuid=user_uuid.bytes, | ||||||
|                     credential_uuid=credential.uuid.bytes, |                     credential_uuid=credential.uuid.bytes, | ||||||
|                     expires=session_expires, |                     host=host, | ||||||
|                     info=session_info, |                     ip=ip, | ||||||
|  |                     user_agent=user_agent, | ||||||
|                 ) |                 ) | ||||||
|             ) |             ) | ||||||
|             # Login side-effects: update user analytics (last_seen + visits increment) |             # Login side-effects: update user analytics (last_seen + visits increment) | ||||||
| @@ -475,17 +595,21 @@ class DB(DatabaseInterface): | |||||||
|         self, |         self, | ||||||
|         user_uuid: UUID, |         user_uuid: UUID, | ||||||
|         key: bytes, |         key: bytes, | ||||||
|         expires: datetime, |         credential_uuid: UUID, | ||||||
|         info: dict, |         host: str, | ||||||
|         credential_uuid: UUID | None = None, |         ip: str, | ||||||
|  |         user_agent: str, | ||||||
|  |         renewed: datetime, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         async with self.session() as session: |         async with self.session() as session: | ||||||
|             session_model = SessionModel( |             session_model = SessionModel( | ||||||
|                 key=key, |                 key=key, | ||||||
|                 user_uuid=user_uuid.bytes, |                 user_uuid=user_uuid.bytes, | ||||||
|                 credential_uuid=credential_uuid.bytes if credential_uuid else None, |                 credential_uuid=credential_uuid.bytes, | ||||||
|                 expires=expires, |                 host=host, | ||||||
|                 info=info, |                 ip=ip, | ||||||
|  |                 user_agent=user_agent, | ||||||
|  |                 renewed=renewed, | ||||||
|             ) |             ) | ||||||
|             session.add(session_model) |             session.add(session_model) | ||||||
|  |  | ||||||
| @@ -496,29 +620,88 @@ class DB(DatabaseInterface): | |||||||
|             session_model = result.scalar_one_or_none() |             session_model = result.scalar_one_or_none() | ||||||
|  |  | ||||||
|             if session_model: |             if session_model: | ||||||
|                 return Session( |                 return session_model.as_dataclass() | ||||||
|                     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 None |             return None | ||||||
|  |  | ||||||
|     async def delete_session(self, key: bytes) -> None: |     async def delete_session(self, key: bytes) -> None: | ||||||
|         async with self.session() as session: |         async with self.session() as session: | ||||||
|             await session.execute(delete(SessionModel).where(SessionModel.key == key)) |             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: |         async with self.session() as session: | ||||||
|             await session.execute( |             await session.execute( | ||||||
|                 update(SessionModel) |                 delete(SessionModel).where(SessionModel.user_uuid == user_uuid.bytes) | ||||||
|                 .where(SessionModel.key == key) |  | ||||||
|                 .values(expires=expires, info=info) |  | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|  |     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 |     # Organization operations | ||||||
|     async def create_organization(self, org: Org) -> None: |     async def create_organization(self, org: Org) -> None: | ||||||
|         async with self.session() as session: |         async with self.session() as session: | ||||||
| @@ -971,8 +1154,10 @@ class DB(DatabaseInterface): | |||||||
|             ) |             ) | ||||||
|             if role.permissions: |             if role.permissions: | ||||||
|                 for perm_id in set(role.permissions): |                 for perm_id in set(role.permissions): | ||||||
|                     session.add( |                     await session.execute( | ||||||
|                         RolePermission(role_uuid=role.uuid.bytes, permission_id=perm_id) |                         insert(RolePermission).values( | ||||||
|  |                             role_uuid=role.uuid.bytes, permission_id=perm_id | ||||||
|  |                         ) | ||||||
|                     ) |                     ) | ||||||
|  |  | ||||||
|     async def delete_role(self, role_uuid: UUID) -> None: |     async def delete_role(self, role_uuid: UUID) -> None: | ||||||
| @@ -1112,11 +1297,18 @@ class DB(DatabaseInterface): | |||||||
|  |  | ||||||
|     async def cleanup(self) -> None: |     async def cleanup(self) -> None: | ||||||
|         async with self.session() as session: |         async with self.session() as session: | ||||||
|             current_time = datetime.now() |             current_time = datetime.now(timezone.utc) | ||||||
|             stmt = delete(SessionModel).where(SessionModel.expires < current_time) |             session_threshold = current_time - SESSION_LIFETIME | ||||||
|             await session.execute(stmt) |             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. |         """Get complete session context including user, organization, role, and permissions. | ||||||
|  |  | ||||||
|         Uses efficient JOINs to retrieve all related data in a single database query. |         Uses efficient JOINs to retrieve all related data in a single database query. | ||||||
| @@ -1153,15 +1345,18 @@ class DB(DatabaseInterface): | |||||||
|             session_model, user_model, role_model, org_model, _ = first_row |             session_model, user_model, role_model, org_model, _ = first_row | ||||||
|  |  | ||||||
|             # Create the session object |             # Create the session object | ||||||
|             session_obj = Session( |             if host is not None: | ||||||
|                 key=session_model.key, |                 if session_model.host is None: | ||||||
|                 user_uuid=UUID(bytes=session_model.user_uuid), |                     await session.execute( | ||||||
|                 credential_uuid=UUID(bytes=session_model.credential_uuid) |                         update(SessionModel) | ||||||
|                 if session_model.credential_uuid |                         .where(SessionModel.key == session_key) | ||||||
|                 else None, |                         .values(host=host) | ||||||
|                 expires=session_model.expires, |  | ||||||
|                 info=session_model.info or {}, |  | ||||||
|                     ) |                     ) | ||||||
|  |                     session_model.host = host | ||||||
|  |                 elif session_model.host != host: | ||||||
|  |                     return None | ||||||
|  |  | ||||||
|  |             session_obj = session_model.as_dataclass() | ||||||
|  |  | ||||||
|             # Create the user object |             # Create the user object | ||||||
|             user_obj = user_model.as_dataclass() |             user_obj = user_model.as_dataclass() | ||||||
| @@ -1200,10 +1395,15 @@ class DB(DatabaseInterface): | |||||||
|             org_perm_result = await session.execute(org_perm_stmt) |             org_perm_result = await session.execute(org_perm_stmt) | ||||||
|             organization.permissions = [row[0] for row in org_perm_result.fetchall()] |             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( |             return SessionContext( | ||||||
|                 session=session_obj, |                 session=session_obj, | ||||||
|                 user=user_obj, |                 user=user_obj, | ||||||
|                 org=organization, |                 org=organization, | ||||||
|                 role=role, |                 role=role, | ||||||
|                 permissions=permissions if permissions else None, |                 permissions=effective_permissions if effective_permissions else None, | ||||||
|             ) |             ) | ||||||
|   | |||||||
| @@ -14,6 +14,27 @@ DEFAULT_SERVE_PORT = 4401 | |||||||
| DEFAULT_DEV_PORT = 4402 | 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( | def parse_endpoint( | ||||||
|     value: str | None, default_port: int |     value: str | None, default_port: int | ||||||
| ) -> tuple[str | None, int | None, str | None, bool]: | ) -> 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("--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("--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(): | def main(): | ||||||
| @@ -168,6 +196,17 @@ def main(): | |||||||
|         os.environ["PASSKEY_RP_NAME"] = args.rp_name |         os.environ["PASSKEY_RP_NAME"] = args.rp_name | ||||||
|     if origin: |     if origin: | ||||||
|         os.environ["PASSKEY_ORIGIN"] = 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. |     # One-time initialization + bootstrap before starting any server processes. | ||||||
|     # Lifespan in worker processes will call globals.init with bootstrap disabled. |     # Lifespan in worker processes will call globals.init with bootstrap disabled. | ||||||
|   | |||||||
| @@ -1,14 +1,24 @@ | |||||||
| import logging | import logging | ||||||
|  | from datetime import timezone | ||||||
| from uuid import UUID, uuid4 | 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 fastapi.responses import FileResponse, JSONResponse | ||||||
|  |  | ||||||
| from ..authsession import expires | from ..authsession import reset_expires | ||||||
| from ..globals import db | from ..globals import db | ||||||
| from ..globals import passkey as global_passkey | from ..util import ( | ||||||
| from ..util import frontend, passphrase, permutil, querysafe, tokens |     frontend, | ||||||
|  |     hostutil, | ||||||
|  |     passphrase, | ||||||
|  |     permutil, | ||||||
|  |     querysafe, | ||||||
|  |     tokens, | ||||||
|  |     useragent, | ||||||
|  | ) | ||||||
|  | from ..util.tokens import encode_session_key, session_key | ||||||
| from . import authz | from . import authz | ||||||
|  | from .session import AUTH_COOKIE | ||||||
|  |  | ||||||
| app = FastAPI() | app = FastAPI() | ||||||
|  |  | ||||||
| @@ -25,20 +35,36 @@ async def general_exception_handler(_request, exc: Exception): | |||||||
|  |  | ||||||
|  |  | ||||||
| @app.get("/") | @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: |     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")) |         return FileResponse(frontend.file("admin/index.html")) | ||||||
|     except HTTPException as e: |     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 -------------------- | # -------------------- Organizations -------------------- | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.get("/orgs") | @app.get("/orgs") | ||||||
| async def admin_list_orgs(auth=Cookie(None)): | async def admin_list_orgs(request: Request, auth=AUTH_COOKIE): | ||||||
|     ctx = await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any) |     ctx = await authz.verify( | ||||||
|  |         auth, | ||||||
|  |         ["auth:admin", "auth:org:*"], | ||||||
|  |         match=permutil.has_any, | ||||||
|  |         host=request.headers.get("host"), | ||||||
|  |     ) | ||||||
|     orgs = await db.instance.list_organizations() |     orgs = await db.instance.list_organizations() | ||||||
|     if "auth:admin" not in ctx.role.permissions: |     if "auth:admin" not in ctx.role.permissions: | ||||||
|         orgs = [o for o in orgs if f"auth:org:{o.uuid}" 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") | @app.post("/orgs") | ||||||
| async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)): | async def admin_create_org( | ||||||
|     await authz.verify(auth, ["auth:admin"]) |     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 Org as OrgDC  # local import to avoid cycles | ||||||
|  |     from ..db import Role as RoleDC  # local import to avoid cycles | ||||||
|  |  | ||||||
|     org_uuid = uuid4() |     org_uuid = uuid4() | ||||||
|     display_name = payload.get("display_name") or "New Organization" |     display_name = payload.get("display_name") or "New Organization" | ||||||
|     permissions = payload.get("permissions") or [] |     permissions = payload.get("permissions") or [] | ||||||
|     org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) |     org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) | ||||||
|     await db.instance.create_organization(org) |     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)} |     return {"uuid": str(org_uuid)} | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.put("/orgs/{org_uuid}") | @app.put("/orgs/{org_uuid}") | ||||||
| async def admin_update_org( | 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( |     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"), | ||||||
|     ) |     ) | ||||||
|     from ..db import Org as OrgDC  # local import to avoid cycles |     from ..db import Org as OrgDC  # local import to avoid cycles | ||||||
|  |  | ||||||
|     current = await db.instance.get_organization(str(org_uuid)) |     current = await db.instance.get_organization(str(org_uuid)) | ||||||
|     display_name = payload.get("display_name") or current.display_name |     display_name = payload.get("display_name") or current.display_name | ||||||
|     permissions = payload.get("permissions") or current.permissions or [] |     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) |     org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) | ||||||
|     await db.instance.update_organization(org) |     await db.instance.update_organization(org) | ||||||
|     return {"status": "ok"} |     return {"status": "ok"} | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.delete("/orgs/{org_uuid}") | @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( |     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: |     if ctx.org.uuid == org_uuid: | ||||||
|         raise ValueError("Cannot delete the organization you belong to") |         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) |     await db.instance.delete_organization(org_uuid) | ||||||
|     return {"status": "ok"} |     return {"status": "ok"} | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.post("/orgs/{org_uuid}/permission") | @app.post("/orgs/{org_uuid}/permission") | ||||||
| async def admin_add_org_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) |     await db.instance.add_permission_to_organization(str(org_uuid), permission_id) | ||||||
|     return {"status": "ok"} |     return {"status": "ok"} | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.delete("/orgs/{org_uuid}/permission") | @app.delete("/orgs/{org_uuid}/permission") | ||||||
| async def admin_remove_org_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) |     await db.instance.remove_permission_from_organization(str(org_uuid), permission_id) | ||||||
|     return {"status": "ok"} |     return {"status": "ok"} | ||||||
|  |  | ||||||
| @@ -137,9 +227,17 @@ async def admin_remove_org_permission( | |||||||
|  |  | ||||||
| @app.post("/orgs/{org_uuid}/roles") | @app.post("/orgs/{org_uuid}/roles") | ||||||
| async def admin_create_role( | 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 |     from ..db import Role as RoleDC | ||||||
|  |  | ||||||
|     role_uuid = uuid4() |     role_uuid = uuid4() | ||||||
| @@ -163,11 +261,18 @@ async def admin_create_role( | |||||||
|  |  | ||||||
| @app.put("/orgs/{org_uuid}/roles/{role_uuid}") | @app.put("/orgs/{org_uuid}/roles/{role_uuid}") | ||||||
| async def admin_update_role( | 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 |     # Verify caller is global admin or admin of provided org | ||||||
|     await authz.verify( |     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"), | ||||||
|     ) |     ) | ||||||
|     role = await db.instance.get_role(role_uuid) |     role = await db.instance.get_role(role_uuid) | ||||||
|     if role.org_uuid != org_uuid: |     if role.org_uuid != org_uuid: | ||||||
| @@ -175,13 +280,25 @@ async def admin_update_role( | |||||||
|     from ..db import Role as RoleDC |     from ..db import Role as RoleDC | ||||||
|  |  | ||||||
|     display_name = payload.get("display_name") or role.display_name |     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)) |     org = await db.instance.get_organization(str(org_uuid)) | ||||||
|     grantable = set(org.permissions or []) |     grantable = set(org.permissions or []) | ||||||
|  |     existing_permissions = set(role.permissions) | ||||||
|     for pid in permissions: |     for pid in permissions: | ||||||
|         await db.instance.get_permission(pid) |         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}") |             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( |     updated = RoleDC( | ||||||
|         uuid=role_uuid, |         uuid=role_uuid, | ||||||
|         org_uuid=org_uuid, |         org_uuid=org_uuid, | ||||||
| @@ -193,13 +310,26 @@ async def admin_update_role( | |||||||
|  |  | ||||||
|  |  | ||||||
| @app.delete("/orgs/{org_uuid}/roles/{role_uuid}") | @app.delete("/orgs/{org_uuid}/roles/{role_uuid}") | ||||||
| async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)): | async def admin_delete_role( | ||||||
|     await authz.verify( |     org_uuid: UUID, | ||||||
|         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any |     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) |     role = await db.instance.get_role(role_uuid) | ||||||
|     if role.org_uuid != org_uuid: |     if role.org_uuid != org_uuid: | ||||||
|         raise HTTPException(status_code=404, detail="Role not found in organization") |         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) |     await db.instance.delete_role(role_uuid) | ||||||
|     return {"status": "ok"} |     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") | @app.post("/orgs/{org_uuid}/users") | ||||||
| async def admin_create_user( | 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( |     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") |     display_name = payload.get("display_name") | ||||||
|     role_name = payload.get("role") |     role_name = payload.get("role") | ||||||
| @@ -238,10 +374,17 @@ async def admin_create_user( | |||||||
|  |  | ||||||
| @app.put("/orgs/{org_uuid}/users/{user_uuid}/role") | @app.put("/orgs/{org_uuid}/users/{user_uuid}/role") | ||||||
| async def admin_update_user_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( |     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"), | ||||||
|     ) |     ) | ||||||
|     new_role = payload.get("role") |     new_role = payload.get("role") | ||||||
|     if not new_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)) |     roles = await db.instance.get_roles_by_organization(str(org_uuid)) | ||||||
|     if not any(r.display_name == new_role for r in roles): |     if not any(r.display_name == new_role for r in roles): | ||||||
|         raise ValueError("Role not found in organization") |         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) |     await db.instance.update_user_role_in_organization(user_uuid, new_role) | ||||||
|     return {"status": "ok"} |     return {"status": "ok"} | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link") | @app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link") | ||||||
| async def admin_create_user_registration_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: |     try: | ||||||
|         user_org, _role_name = await db.instance.get_user_organization(user_uuid) |         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: |     if user_org.uuid != org_uuid: | ||||||
|         raise HTTPException(status_code=404, detail="User not found in organization") |         raise HTTPException(status_code=404, detail="User not found in organization") | ||||||
|     ctx = await authz.verify( |     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 ( |     if ( | ||||||
|         "auth:admin" not in ctx.role.permissions |         "auth:admin" not in ctx.role.permissions | ||||||
|         and f"auth:org:{org_uuid}" not in ctx.role.permissions |         and f"auth:org:{org_uuid}" not in ctx.role.permissions | ||||||
|     ): |     ): | ||||||
|         raise HTTPException(status_code=403, detail="Insufficient permissions") |         raise 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() |     token = passphrase.generate() | ||||||
|     await db.instance.create_session( |     expiry = reset_expires() | ||||||
|  |     await db.instance.create_reset_token( | ||||||
|         user_uuid=user_uuid, |         user_uuid=user_uuid, | ||||||
|         key=tokens.reset_key(token), |         key=tokens.reset_key(token), | ||||||
|         expires=expires(), |         expiry=expiry, | ||||||
|         info={"type": "device addition", "created_by_admin": True}, |         token_type=token_type, | ||||||
|     ) |     ) | ||||||
|     origin = global_passkey.instance.origin |     url = hostutil.reset_link_url( | ||||||
|     url = f"{origin}/auth/{token}" |         token, request.url.scheme, request.headers.get("host") | ||||||
|     return {"url": url, "expires": expires().isoformat()} |     ) | ||||||
|  |     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}") | @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: |     try: | ||||||
|         user_org, role_name = await db.instance.get_user_organization(user_uuid) |         user_org, role_name = await db.instance.get_user_organization(user_uuid) | ||||||
|     except ValueError: |     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: |     if user_org.uuid != org_uuid: | ||||||
|         raise HTTPException(status_code=404, detail="User not found in organization") |         raise HTTPException(status_code=404, detail="User not found in organization") | ||||||
|     ctx = await authz.verify( |     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 ( |     if ( | ||||||
|         "auth:admin" not in ctx.role.permissions |         "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), |                 "credential_uuid": str(c.uuid), | ||||||
|                 "aaguid": aaguid_str, |                 "aaguid": aaguid_str, | ||||||
|                 "created_at": c.created_at.isoformat(), |                 "created_at": ( | ||||||
|                 "last_used": c.last_used.isoformat() if c.last_used else None, |                     c.created_at.astimezone(timezone.utc) | ||||||
|                 "last_verified": c.last_verified.isoformat() |                     .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 |                 if c.last_verified | ||||||
|                 else None, |                 else None, | ||||||
|                 "sign_count": c.sign_count, |                 "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 |     from .. import aaguid as aaguid_mod | ||||||
|  |  | ||||||
|     aaguid_info = aaguid_mod.filter(aaguids) |     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 { |     return { | ||||||
|         "display_name": user.display_name, |         "display_name": user.display_name, | ||||||
|         "org": {"display_name": user_org.display_name}, |         "org": {"display_name": user_org.display_name}, | ||||||
|         "role": role_name, |         "role": role_name, | ||||||
|         "visits": user.visits, |         "visits": user.visits, | ||||||
|         "created_at": user.created_at.isoformat() if user.created_at else None, |         "created_at": ( | ||||||
|         "last_seen": user.last_seen.isoformat() if user.last_seen else None, |             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, |         "credentials": creds, | ||||||
|         "aaguid_info": aaguid_info, |         "aaguid_info": aaguid_info, | ||||||
|  |         "sessions": sessions_payload, | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.put("/orgs/{org_uuid}/users/{user_uuid}/display-name") | @app.put("/orgs/{org_uuid}/users/{user_uuid}/display-name") | ||||||
| async def admin_update_user_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: |     try: | ||||||
|         user_org, _role_name = await db.instance.get_user_organization(user_uuid) |         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: |     if user_org.uuid != org_uuid: | ||||||
|         raise HTTPException(status_code=404, detail="User not found in organization") |         raise HTTPException(status_code=404, detail="User not found in organization") | ||||||
|     ctx = await authz.verify( |     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 ( |     if ( | ||||||
|         "auth:admin" not in ctx.role.permissions |         "auth:admin" not in ctx.role.permissions | ||||||
| @@ -370,19 +646,67 @@ async def admin_update_user_display_name( | |||||||
|     return {"status": "ok"} |     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) -------------------- | # -------------------- Permissions (global) -------------------- | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.get("/permissions") | @app.get("/permissions") | ||||||
| async def admin_list_permissions(auth=Cookie(None)): | async def admin_list_permissions(request: Request, auth=AUTH_COOKIE): | ||||||
|     await authz.verify(auth, ["auth:admin"], match=permutil.has_any) |     ctx = await authz.verify( | ||||||
|  |         auth, | ||||||
|  |         ["auth:admin", "auth:org:*"], | ||||||
|  |         match=permutil.has_any, | ||||||
|  |         host=request.headers.get("host"), | ||||||
|  |     ) | ||||||
|     perms = await db.instance.list_permissions() |     perms = await db.instance.list_permissions() | ||||||
|  |  | ||||||
|  |     # 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] |         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") | @app.post("/permissions") | ||||||
| async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)): | async def admin_create_permission( | ||||||
|     await authz.verify(auth, ["auth:admin"]) |     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 |     from ..db import Permission as PermDC | ||||||
|  |  | ||||||
|     perm_id = payload.get("id") |     perm_id = payload.get("id") | ||||||
| @@ -396,9 +720,14 @@ async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)): | |||||||
|  |  | ||||||
| @app.put("/permission") | @app.put("/permission") | ||||||
| async def admin_update_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 |     from ..db import Permission as PermDC | ||||||
|  |  | ||||||
|     if not display_name: |     if not display_name: | ||||||
| @@ -411,13 +740,24 @@ async def admin_update_permission( | |||||||
|  |  | ||||||
|  |  | ||||||
| @app.post("/permission/rename") | @app.post("/permission/rename") | ||||||
| async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)): | async def admin_rename_permission( | ||||||
|     await authz.verify(auth, ["auth:admin"]) |     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") |     old_id = payload.get("old_id") | ||||||
|     new_id = payload.get("new_id") |     new_id = payload.get("new_id") | ||||||
|     display_name = payload.get("display_name") |     display_name = payload.get("display_name") | ||||||
|     if not old_id or not new_id: |     if not old_id or not new_id: | ||||||
|         raise ValueError("old_id and new_id required") |         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(old_id, field="old_id") | ||||||
|     querysafe.assert_safe(new_id, field="new_id") |     querysafe.assert_safe(new_id, field="new_id") | ||||||
|     if display_name is None: |     if display_name is None: | ||||||
| @@ -431,8 +771,19 @@ async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)): | |||||||
|  |  | ||||||
|  |  | ||||||
| @app.delete("/permission") | @app.delete("/permission") | ||||||
| async def admin_delete_permission(permission_id: str, auth=Cookie(None)): | async def admin_delete_permission( | ||||||
|     await authz.verify(auth, ["auth:admin"]) |     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") |     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) |     await db.instance.delete_permission(permission_id) | ||||||
|     return {"status": "ok"} |     return {"status": "ok"} | ||||||
|   | |||||||
| @@ -1,11 +1,8 @@ | |||||||
| import logging | import logging | ||||||
| from contextlib import suppress | from contextlib import suppress | ||||||
| from datetime import datetime, timedelta | from datetime import datetime, timedelta, timezone | ||||||
| from uuid import UUID |  | ||||||
|  |  | ||||||
| from fastapi import ( | from fastapi import ( | ||||||
|     Body, |  | ||||||
|     Cookie, |  | ||||||
|     Depends, |     Depends, | ||||||
|     FastAPI, |     FastAPI, | ||||||
|     HTTPException, |     HTTPException, | ||||||
| @@ -13,30 +10,43 @@ from fastapi import ( | |||||||
|     Request, |     Request, | ||||||
|     Response, |     Response, | ||||||
| ) | ) | ||||||
| from fastapi.responses import FileResponse, JSONResponse | from fastapi.responses import JSONResponse | ||||||
| from fastapi.security import HTTPBearer | from fastapi.security import HTTPBearer | ||||||
|  |  | ||||||
| from passkey.util import frontend | from passkey.util import frontend, useragent | ||||||
|  |  | ||||||
| from .. import aaguid | from .. import aaguid | ||||||
| from ..authsession import ( | from ..authsession import ( | ||||||
|     EXPIRES, |     EXPIRES, | ||||||
|     delete_credential, |  | ||||||
|     expires, |  | ||||||
|     get_reset, |     get_reset, | ||||||
|     get_session, |     get_session, | ||||||
|     refresh_session_token, |     refresh_session_token, | ||||||
|  |     session_expiry, | ||||||
| ) | ) | ||||||
| from ..globals import db | from ..globals import db | ||||||
| from ..globals import passkey as global_passkey | from ..globals import passkey as global_passkey | ||||||
| from ..util import passphrase, permutil, tokens | from ..util import hostutil, passphrase, permutil | ||||||
| from ..util.tokens import session_key | from ..util.tokens import encode_session_key, session_key | ||||||
| from . import authz, session | from . import authz, session, user | ||||||
|  | from .session import AUTH_COOKIE | ||||||
|  |  | ||||||
| bearer_auth = HTTPBearer(auto_error=True) | bearer_auth = HTTPBearer(auto_error=True) | ||||||
|  |  | ||||||
| app = FastAPI() | 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*. | # Refresh only if at least this much of the session lifetime has been *consumed*. | ||||||
| # Consumption is derived from (now + EXPIRES) - current_expires. | # Consumption is derived from (now + EXPIRES) - current_expires. | ||||||
| # This guarantees a minimum spacing between DB writes even with frequent /validate calls. | # 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") | @app.post("/validate") | ||||||
| async def validate_token( | 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. |     """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 |     renewed max-age. This keeps active users logged in without needing a separate | ||||||
|     refresh endpoint. |     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 |     renewed = False | ||||||
|     if auth: |     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: |         if not timedelta(0) < consumed < _REFRESH_INTERVAL: | ||||||
|             try: |             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) |                 session.set_session_cookie(response, auth) | ||||||
|                 renewed = True |                 renewed = True | ||||||
|             except ValueError: |             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") |                 raise HTTPException(status_code=401, detail="Session expired") | ||||||
|     return { |     return { | ||||||
|         "valid": True, |         "valid": True, | ||||||
| @@ -84,7 +106,12 @@ async def validate_token( | |||||||
|  |  | ||||||
|  |  | ||||||
| @app.get("/forward") | @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. |     """Forward auth validation for Caddy/Nginx. | ||||||
|  |  | ||||||
|     Query Params: |     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. |     Failure (unauthenticated / unauthorized): 4xx JSON body with detail. | ||||||
|     """ |     """ | ||||||
|     try: |     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 []) |         role_permissions = set(ctx.role.permissions or []) | ||||||
|         if ctx.permissions: |         if ctx.permissions: | ||||||
|             role_permissions.update(permission.id for permission in ctx.permissions) |             role_permissions.update(permission.id for permission in ctx.permissions) | ||||||
| @@ -107,49 +134,83 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None)) | |||||||
|             "Remote-Org-Name": ctx.org.display_name, |             "Remote-Org-Name": ctx.org.display_name, | ||||||
|             "Remote-Role": str(ctx.role.uuid), |             "Remote-Role": str(ctx.role.uuid), | ||||||
|             "Remote-Role-Name": ctx.role.display_name, |             "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), |             "Remote-Credential": str(ctx.session.credential_uuid), | ||||||
|         } |         } | ||||||
|         return Response(status_code=204, headers=remote_headers) |         return Response(status_code=204, headers=remote_headers) | ||||||
|     except HTTPException as e: |     except 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") | @app.get("/settings") | ||||||
| async def get_settings(): | async def get_settings(): | ||||||
|     pk = global_passkey.instance |     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") | @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 |     authenticated = False | ||||||
|  |     session_record = None | ||||||
|  |     reset_token = None | ||||||
|     try: |     try: | ||||||
|         if reset: |         if reset: | ||||||
|             if not passphrase.is_well_formed(reset): |             if not passphrase.is_well_formed(reset): | ||||||
|                 raise ValueError("Invalid reset token") |                 raise ValueError("Invalid reset token") | ||||||
|             s = await get_reset(reset) |             reset_token = await get_reset(reset) | ||||||
|  |             target_user_uuid = reset_token.user_uuid | ||||||
|         else: |         else: | ||||||
|             if auth is None: |             if auth is None: | ||||||
|                 raise ValueError("Authentication Required") |                 raise ValueError("Authentication Required") | ||||||
|             s = await get_session(auth) |             session_record = await get_session(auth, host=request.headers.get("host")) | ||||||
|             authenticated = True |             authenticated = True | ||||||
|  |             target_user_uuid = session_record.user_uuid | ||||||
|     except ValueError as e: |     except ValueError as e: | ||||||
|         raise HTTPException(401, str(e)) |         raise HTTPException(401, str(e)) | ||||||
|  |  | ||||||
|     u = await db.instance.get_user_by_uuid(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 { |         return { | ||||||
|             "authenticated": False, |             "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}, |             "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) |     ctx = await permutil.session_context(auth, request.headers.get("host")) | ||||||
|     credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid) |     credential_ids = await db.instance.get_credentials_by_user_uuid( | ||||||
|  |         session_record.user_uuid | ||||||
|  |     ) | ||||||
|     credentials: list[dict] = [] |     credentials: list[dict] = [] | ||||||
|     user_aaguids: set[str] = set() |     user_aaguids: set[str] = set() | ||||||
|     for cred_id in credential_ids: |     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), |                 "credential_uuid": str(c.uuid), | ||||||
|                 "aaguid": aaguid_str, |                 "aaguid": aaguid_str, | ||||||
|                 "created_at": c.created_at.isoformat(), |                 "created_at": ( | ||||||
|                 "last_used": c.last_used.isoformat() if c.last_used else None, |                     c.created_at.astimezone(timezone.utc) | ||||||
|                 "last_verified": c.last_verified.isoformat() |                     .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 |                 if c.last_verified | ||||||
|                 else None, |                 else None, | ||||||
|                 "sign_count": c.sign_count, |                 "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"]) |     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 [])] |         effective_permissions = [p.id for p in (ctx.permissions or [])] | ||||||
|         is_global_admin = "auth:admin" in (role_info["permissions"] or []) |         is_global_admin = "auth:admin" in (role_info["permissions"] or []) | ||||||
|         if org_info: |         is_org_admin = any( | ||||||
|             is_org_admin = f"auth:org:{org_info['uuid']}" in ( |             p.startswith("auth:org:") for p in (role_info["permissions"] or []) | ||||||
|                 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 { |     return { | ||||||
|         "authenticated": True, |         "authenticated": True, | ||||||
|         "session_type": s.info.get("type"), |  | ||||||
|         "user": { |         "user": { | ||||||
|             "user_uuid": str(u.uuid), |             "user_uuid": str(u.uuid), | ||||||
|             "user_name": u.display_name, |             "user_name": u.display_name, | ||||||
|             "created_at": u.created_at.isoformat() if u.created_at else None, |             "created_at": ( | ||||||
|             "last_seen": u.last_seen.isoformat() if u.last_seen else None, |                 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, |             "visits": u.visits, | ||||||
|         }, |         }, | ||||||
|         "org": org_info, |         "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, |         "is_org_admin": is_org_admin, | ||||||
|         "credentials": credentials, |         "credentials": credentials, | ||||||
|         "aaguid_info": aaguid_info, |         "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") | @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: |     if not auth: | ||||||
|         return {"message": "Already logged out"} |         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): |     with suppress(Exception): | ||||||
|         await db.instance.delete_session(session_key(auth)) |         await db.instance.delete_session(session_key(auth)) | ||||||
|     response.delete_cookie("auth") |     session.clear_session_cookie(response) | ||||||
|     return {"message": "Logged out successfully"} |     return {"message": "Logged out successfully"} | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.post("/set-session") | @app.post("/set-session") | ||||||
| async def api_set_session(response: Response, auth=Depends(bearer_auth)): | async def api_set_session( | ||||||
|     user = await get_session(auth.credentials) |     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) |     session.set_session_cookie(response, auth.credentials) | ||||||
|     return { |     return { | ||||||
|         "message": "Session cookie set successfully", |         "message": "Session cookie set successfully", | ||||||
|         "user_uuid": str(user.user_uuid), |         "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(), |  | ||||||
|     } |  | ||||||
|   | |||||||
							
								
								
									
										97
									
								
								passkey/fastapi/auth_host.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								passkey/fastapi/auth_host.py
									
									
									
									
									
										Normal 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) | ||||||
| @@ -7,7 +7,12 @@ from ..util import permutil | |||||||
| logger = logging.getLogger(__name__) | 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. |     """Validate session token and optional list of required permissions. | ||||||
|  |  | ||||||
|     Returns the session context. |     Returns the session context. | ||||||
| @@ -19,7 +24,7 @@ async def verify(auth: str | None, perm: list[str], match=permutil.has_all): | |||||||
|     if not auth: |     if not auth: | ||||||
|         raise HTTPException(status_code=401, detail="Authentication required") |         raise HTTPException(status_code=401, detail="Authentication required") | ||||||
|  |  | ||||||
|     ctx = await permutil.session_context(auth) |     ctx = await permutil.session_context(auth, host) | ||||||
|     if not ctx: |     if not ctx: | ||||||
|         raise HTTPException(status_code=401, detail="Session not found") |         raise HTTPException(status_code=401, detail="Session not found") | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,13 +2,14 @@ import logging | |||||||
| import os | import os | ||||||
| from contextlib import asynccontextmanager | 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.responses import FileResponse, RedirectResponse | ||||||
| from fastapi.staticfiles import StaticFiles | 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 | @asynccontextmanager | ||||||
| @@ -46,6 +47,10 @@ async def lifespan(app: FastAPI):  # pragma: no cover - startup path | |||||||
|  |  | ||||||
|  |  | ||||||
| app = FastAPI(lifespan=lifespan) | 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/admin/", admin.app) | ||||||
| app.mount("/auth/api/", api.app) | app.mount("/auth/api/", api.app) | ||||||
| app.mount("/auth/ws/", ws.app) | app.mount("/auth/ws/", ws.app) | ||||||
| @@ -53,26 +58,59 @@ app.mount( | |||||||
|     "/auth/assets/", StaticFiles(directory=frontend.file("assets")), name="assets" |     "/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("/") | @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/") | @app.get("/auth/") | ||||||
| async def frontapp(): | async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE): | ||||||
|     """Serve the main authentication app.""" |     """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")) |         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}") | @app.get("/auth/{reset}") | ||||||
| async def reset_link(request: Request, reset: str): | async def reset_link(reset: str): | ||||||
|     """Pretty URL for reset links.""" |     """Serve the SPA directly with an injected reset token.""" | ||||||
|     if reset == "admin": |  | ||||||
|         # Admin app missing trailing slash lands here, be friendly to user |  | ||||||
|         return RedirectResponse(request.url_for("adminapp"), status_code=303) |  | ||||||
|     if not passphrase.is_well_formed(reset): |     if not passphrase.is_well_formed(reset): | ||||||
|         raise HTTPException(status_code=404) |         raise HTTPException(status_code=404) | ||||||
|     url = request.url_for("frontapp").include_query_params(reset=reset) |     return FileResponse(frontend.file("reset", "index.html")) | ||||||
|     return RedirectResponse(url, status_code=303) |  | ||||||
|  |  | ||||||
|  | @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")) | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ from uuid import UUID | |||||||
|  |  | ||||||
| from passkey import authsession as _authsession | from passkey import authsession as _authsession | ||||||
| from passkey import globals as _g | from passkey import globals as _g | ||||||
| from passkey.util import passphrase | from passkey.util import hostutil, passphrase | ||||||
| from passkey.util import tokens as _tokens | 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): | async def _create_reset(user, role_name: str): | ||||||
|     token = passphrase.generate() |     token = passphrase.generate() | ||||||
|     await _g.db.instance.create_session( |     expiry = _authsession.reset_expires() | ||||||
|  |     await _g.db.instance.create_reset_token( | ||||||
|         user_uuid=user.uuid, |         user_uuid=user.uuid, | ||||||
|         key=_tokens.reset_key(token), |         key=_tokens.reset_key(token), | ||||||
|         expires=_authsession.expires(), |         expiry=expiry, | ||||||
|         info={"type": "manual reset", "role": role_name}, |         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: | async def _main(query: str | None) -> int: | ||||||
|   | |||||||
| @@ -8,26 +8,45 @@ This module provides FastAPI-specific session management functionality: | |||||||
| Generic session management functions have been moved to authsession.py | 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 | 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: | def infodict(request: Request | WebSocket, type: str) -> dict: | ||||||
|     """Extract client information from request.""" |     """Extract client information from request.""" | ||||||
|     return { |     return { | ||||||
|         "ip": request.client.host if request.client else "", |         "ip": request.client.host if request.client else None, | ||||||
|         "user_agent": request.headers.get("user-agent", "")[:500], |         "user_agent": request.headers.get("user-agent", "")[:500] or None, | ||||||
|         "type": type, |         "session_type": type, | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |  | ||||||
| def set_session_cookie(response: Response, token: str) -> None: | def set_session_cookie(response: Response, token: str) -> None: | ||||||
|     """Set the session token as an HTTP-only cookie.""" |     """Set the session token as an HTTP-only cookie.""" | ||||||
|     response.set_cookie( |     response.set_cookie( | ||||||
|         key="auth", |         key=AUTH_COOKIE_NAME, | ||||||
|         value=token, |         value=token, | ||||||
|         max_age=int(EXPIRES.total_seconds()), |         max_age=int(EXPIRES.total_seconds()), | ||||||
|         httponly=True, |         httponly=True, | ||||||
|         secure=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
									
								
							
							
						
						
									
										136
									
								
								passkey/fastapi/user.py
									
									
									
									
									
										Normal 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") | ||||||
|  |         ), | ||||||
|  |     } | ||||||
| @@ -2,14 +2,14 @@ import logging | |||||||
| from functools import wraps | from functools import wraps | ||||||
| from uuid import UUID | from uuid import UUID | ||||||
|  |  | ||||||
| from fastapi import Cookie, FastAPI, WebSocket, WebSocketDisconnect | from fastapi import FastAPI, WebSocket, WebSocketDisconnect | ||||||
| from webauthn.helpers.exceptions import InvalidAuthenticationResponse | 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 ..globals import db, passkey | ||||||
| from ..util import passphrase | from ..util import passphrase | ||||||
| from ..util.tokens import create_token, session_key | from ..util.tokens import create_token, session_key | ||||||
| from .session import infodict | from .session import AUTH_COOKIE, infodict | ||||||
|  |  | ||||||
|  |  | ||||||
| # WebSocket error handling decorator | # WebSocket error handling decorator | ||||||
| @@ -56,7 +56,10 @@ async def register_chat( | |||||||
| @app.websocket("/register") | @app.websocket("/register") | ||||||
| @websocket_error_handler | @websocket_error_handler | ||||||
| async def websocket_register_add( | 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. |     """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) |     - Reset token supplied as ?reset=... (auth cookie ignored) | ||||||
|     """ |     """ | ||||||
|     origin = ws.headers["origin"] |     origin = ws.headers["origin"] | ||||||
|  |     host = origin.split("://", 1)[1] | ||||||
|     if reset is not None: |     if reset is not None: | ||||||
|         if not passphrase.is_well_formed(reset): |         if not passphrase.is_well_formed(reset): | ||||||
|             raise ValueError("Invalid reset token") |             raise ValueError("Invalid reset token") | ||||||
| @@ -72,7 +76,7 @@ async def websocket_register_add( | |||||||
|     else: |     else: | ||||||
|         if not auth: |         if not auth: | ||||||
|             raise ValueError("Authentication Required") |             raise ValueError("Authentication Required") | ||||||
|         s = await get_session(auth) |         s = await get_session(auth, host=host) | ||||||
|     user_uuid = s.user_uuid |     user_uuid = s.user_uuid | ||||||
|  |  | ||||||
|     # Get user information and determine effective user_name for this registration |     # 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 |     # Create a new session and store everything in database | ||||||
|     token = create_token() |     token = create_token() | ||||||
|  |     metadata = infodict(ws, "authenticated") | ||||||
|     await db.instance.create_credential_session(  # type: ignore[attr-defined] |     await db.instance.create_credential_session(  # type: ignore[attr-defined] | ||||||
|         user_uuid=user_uuid, |         user_uuid=user_uuid, | ||||||
|         credential=credential, |         credential=credential, | ||||||
|         reset_key=(s.key if reset is not None else None), |         reset_key=(s.key if reset is not None else None), | ||||||
|         session_key=session_key(token), |         session_key=session_key(token), | ||||||
|         session_expires=expires(), |  | ||||||
|         session_info=infodict(ws, "authenticated"), |  | ||||||
|         display_name=user_name, |         display_name=user_name, | ||||||
|  |         host=host, | ||||||
|  |         ip=metadata.get("ip"), | ||||||
|  |         user_agent=metadata.get("user_agent"), | ||||||
|     ) |     ) | ||||||
|     auth = token |     auth = token | ||||||
|  |  | ||||||
| @@ -115,6 +121,7 @@ async def websocket_register_add( | |||||||
| @websocket_error_handler | @websocket_error_handler | ||||||
| async def websocket_authenticate(ws: WebSocket): | async def websocket_authenticate(ws: WebSocket): | ||||||
|     origin = ws.headers["origin"] |     origin = ws.headers["origin"] | ||||||
|  |     host = origin.split("://", 1)[1] | ||||||
|     options, challenge = passkey.instance.auth_generate_options() |     options, challenge = passkey.instance.auth_generate_options() | ||||||
|     await ws.send_json(options) |     await ws.send_json(options) | ||||||
|     # Wait for the client to use his authenticator to authenticate |     # 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 |     # Create a session token for the authenticated user | ||||||
|     assert stored_cred.uuid is not None |     assert stored_cred.uuid is not None | ||||||
|  |     metadata = infodict(ws, "auth") | ||||||
|     token = await create_session( |     token = await create_session( | ||||||
|         user_uuid=stored_cred.user_uuid, |         user_uuid=stored_cred.user_uuid, | ||||||
|         info=infodict(ws, "auth"), |  | ||||||
|         credential_uuid=stored_cred.uuid, |         credential_uuid=stored_cred.uuid, | ||||||
|  |         host=host, | ||||||
|  |         ip=metadata.get("ip") or "", | ||||||
|  |         user_agent=metadata.get("user_agent") or "", | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     await ws.send_json( |     await ws.send_json( | ||||||
|   | |||||||
| @@ -8,7 +8,7 @@ This module provides a unified interface for WebAuthn operations including: | |||||||
| """ | """ | ||||||
|  |  | ||||||
| import json | import json | ||||||
| from datetime import datetime | from datetime import datetime, timezone | ||||||
| from urllib.parse import urlparse | from urllib.parse import urlparse | ||||||
| from uuid import UUID | from uuid import UUID | ||||||
|  |  | ||||||
| @@ -163,7 +163,7 @@ class Passkey: | |||||||
|             aaguid=UUID(registration.aaguid), |             aaguid=UUID(registration.aaguid), | ||||||
|             public_key=registration.credential_public_key, |             public_key=registration.credential_public_key, | ||||||
|             sign_count=registration.sign_count, |             sign_count=registration.sign_count, | ||||||
|             created_at=datetime.now(), |             created_at=datetime.now(timezone.utc), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     ### Authentication Methods ### |     ### Authentication Methods ### | ||||||
| @@ -227,7 +227,7 @@ class Passkey: | |||||||
|             credential_current_sign_count=stored_cred.sign_count, |             credential_current_sign_count=stored_cred.sign_count, | ||||||
|         ) |         ) | ||||||
|         stored_cred.sign_count = verification.new_sign_count |         stored_cred.sign_count = verification.new_sign_count | ||||||
|         now = datetime.now() |         now = datetime.now(timezone.utc) | ||||||
|         stored_cred.last_used = now |         stored_cred.last_used = now | ||||||
|         if verification.user_verified: |         if verification.user_verified: | ||||||
|             stored_cred.last_verified = now |             stored_cred.last_verified = now | ||||||
|   | |||||||
							
								
								
									
										92
									
								
								passkey/util/hostutil.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								passkey/util/hostutil.py
									
									
									
									
									
										Normal 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 | ||||||
| @@ -4,6 +4,7 @@ from collections.abc import Sequence | |||||||
| from fnmatch import fnmatchcase | from fnmatch import fnmatchcase | ||||||
|  |  | ||||||
| from ..globals import db | from ..globals import db | ||||||
|  | from .hostutil import normalize_host | ||||||
| from .tokens import session_key | from .tokens import session_key | ||||||
|  |  | ||||||
| __all__ = ["has_any", "has_all", "session_context"] | __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 |     return all(_match(ctx.role.permissions, patterns)) if ctx else False | ||||||
|  |  | ||||||
|  |  | ||||||
| async def session_context(auth: str | None): | async def session_context(auth: str | None, host: str | None = None): | ||||||
|     return await db.instance.get_session_context(session_key(auth)) if auth else 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) | ||||||
|   | |||||||
| @@ -15,6 +15,25 @@ def session_key(token: str) -> bytes: | |||||||
|     return b"sess" + base64.urlsafe_b64decode(token) |     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: | def reset_key(passphrase: str) -> bytes: | ||||||
|     if not is_well_formed(passphrase): |     if not is_well_formed(passphrase): | ||||||
|         raise ValueError( |         raise ValueError( | ||||||
|   | |||||||
							
								
								
									
										10
									
								
								passkey/util/useragent.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								passkey/util/useragent.py
									
									
									
									
									
										Normal 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() | ||||||
| @@ -1,10 +1,10 @@ | |||||||
| [build-system] | [build-system] | ||||||
| requires = ["hatchling"] | requires = ["hatchling", "hatch-vcs"] | ||||||
| build-backend = "hatchling.build" | build-backend = "hatchling.build" | ||||||
|  |  | ||||||
| [project] | [project] | ||||||
| name = "passkey" | name = "passkey" | ||||||
| version = "0.1.2" | dynamic = ["version"] | ||||||
| description = "Passkey Authentication for Web Services" | description = "Passkey Authentication for Web Services" | ||||||
| authors = [ | authors = [ | ||||||
|     {name = "Leo Vasanko"}, |     {name = "Leo Vasanko"}, | ||||||
| @@ -18,9 +18,16 @@ dependencies = [ | |||||||
|     "aiosqlite>=0.19.0", |     "aiosqlite>=0.19.0", | ||||||
|     "uuid7-standard>=1.0.0", |     "uuid7-standard>=1.0.0", | ||||||
|     "pyjwt>=2.8.0", |     "pyjwt>=2.8.0", | ||||||
|  |     "user-agents>=2.2.0", | ||||||
| ] | ] | ||||||
| requires-python = ">=3.10" | requires-python = ">=3.10" | ||||||
|  |  | ||||||
|  | [tool.hatch.version] | ||||||
|  | source = "vcs" | ||||||
|  |  | ||||||
|  | [tool.hatch.build.hooks.vcs] | ||||||
|  | version-file = "passkey/_version.py" | ||||||
|  |  | ||||||
| [project.optional-dependencies] | [project.optional-dependencies] | ||||||
| dev = [ | dev = [ | ||||||
|     "ruff>=0.1.0", |     "ruff>=0.1.0", | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user