Compare commits
	
		
			15 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 963ab06664 | ||
|   | bb35e57ba4 | ||
|   | 5d8304bbd9 | ||
|   | fbfd0bbb47 | ||
|   | eb38995cca | ||
|   | 382341e5ee | ||
|   | ed7d3ee0fc | ||
|   | 3dff459068 | ||
|   | 89b40cd080 | ||
|   | d46d50b91a | ||
|   | 39beb31347 | ||
|   | 41e6eb9a5a | ||
|   | d5bc3e773d | ||
|   | ac0256c366 | ||
|   | 6439437e8b | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -6,3 +6,4 @@ dist/ | ||||
| passkey-auth.sqlite | ||||
| /passkey/frontend-build | ||||
| /test_*.py | ||||
| passkey/_version.py | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| localhost { | ||||
| 	# Setup the authentication site at /auth/ | ||||
| 	import auth/setup | ||||
| 	# Only users with myapp:reports and auth admin permissions | ||||
| 	handle_path /reports { | ||||
| @@ -22,16 +23,3 @@ localhost { | ||||
| 		reverse_proxy :3000 | ||||
| 	} | ||||
| } | ||||
|  | ||||
| example.com { | ||||
| 	# Public endpoints in handle blocks before auth | ||||
| 	@public path /favicon.ico /.well-known/* | ||||
| 	handle @public { | ||||
| 		root * /var/www/ | ||||
| 		file_server | ||||
| 	} | ||||
| 	# The rest of the site protected, /auth/ reserved for auth service | ||||
| 	import auth/all perm=auth:admin { | ||||
| 		reverse_proxy :3000 | ||||
| 	} | ||||
| } | ||||
| @@ -1,6 +0,0 @@ | ||||
| # Enable auth site at /auth (setup) and require authentication on all paths | ||||
| import setup | ||||
| handle { | ||||
|     import require {args[0]} | ||||
|     {block} | ||||
| } | ||||
| @@ -1,5 +1,7 @@ | ||||
| # Permission to use within your endpoints that need authentication/authorization, that | ||||
| # is different depending on the route (otherwise use auth/all). | ||||
| # Permission to use within your endpoints that need authentication/authorization | ||||
| # Argument is mandatory and provides a query string to /auth/api/forward | ||||
| #   "" means just authentication | ||||
| #   perm=yourservice:login to require specific permission | ||||
| forward_auth {$AUTH_UPSTREAM:localhost:4401} { | ||||
|     uri /auth/api/forward?{args[0]} | ||||
|     header_up Connection keep-alive  # Much higher performance | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
|   <head> | ||||
|     <meta charset="UTF-8"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|     <title>Authentication</title> | ||||
|     <title>Auth Profile</title> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="app"></div> | ||||
|   | ||||
							
								
								
									
										12
									
								
								frontend/reset/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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,35 +1,33 @@ | ||||
| <template> | ||||
|   <div> | ||||
|   <div class="app-shell"> | ||||
|     <StatusMessage /> | ||||
|     <main class="app-main"> | ||||
|       <!-- Only render views after authentication status is determined --> | ||||
|       <template v-if="initialized"> | ||||
|         <LoginView v-if="store.currentView === 'login'" /> | ||||
|         <ProfileView v-if="store.currentView === 'profile'" /> | ||||
|         <DeviceLinkView v-if="store.currentView === 'device-link'" /> | ||||
|     <ResetView v-if="store.currentView === 'reset'" /> | ||||
|   <PermissionDeniedView v-if="store.currentView === 'permission-denied'" /> | ||||
|       </template> | ||||
|       <!-- Show loading state while determining auth status --> | ||||
|       <div v-else class="loading-container"> | ||||
|         <div class="loading-spinner"></div> | ||||
|         <p>Loading...</p> | ||||
|       </div> | ||||
|     </main> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { onMounted } from 'vue' | ||||
| import { onMounted, ref } from 'vue' | ||||
| import { useAuthStore } from '@/stores/auth' | ||||
| import StatusMessage from '@/components/StatusMessage.vue' | ||||
| import LoginView from '@/components/LoginView.vue' | ||||
| import ProfileView from '@/components/ProfileView.vue' | ||||
| import DeviceLinkView from '@/components/DeviceLinkView.vue' | ||||
| import ResetView from '@/components/ResetView.vue' | ||||
| import PermissionDeniedView from '@/components/PermissionDeniedView.vue' | ||||
|  | ||||
| const store = useAuthStore() | ||||
| const initialized = ref(false) | ||||
|  | ||||
| onMounted(async () => { | ||||
|   // Detect restricted mode: | ||||
|   // We only allow full functionality on the exact /auth/ (or /auth) path. | ||||
|   // Any other path (including /, /foo, /auth/admin, etc.) is treated as restricted | ||||
|   // so the app will only show login or permission denied views. | ||||
|   const path = location.pathname | ||||
|   if (!(path === '/auth/' || path === '/auth')) { | ||||
|     store.setRestrictedMode(true) | ||||
|   } | ||||
|   // Load branding / settings first (non-blocking for auth flow) | ||||
|   await store.loadSettings() | ||||
|   // Was an error message passed in the URL hash? | ||||
| @@ -38,22 +36,43 @@ onMounted(async () => { | ||||
|     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' | ||||
|   } | ||||
|   } finally { | ||||
|     initialized.value = true | ||||
|     store.selectView() | ||||
|   } | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .loading-container { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   height: 100vh; | ||||
|   gap: 1rem; | ||||
| } | ||||
|  | ||||
| .loading-spinner { | ||||
|   width: 40px; | ||||
|   height: 40px; | ||||
|   border: 4px solid var(--color-border); | ||||
|   border-top: 4px solid var(--color-primary); | ||||
|   border-radius: 50%; | ||||
|   animation: spin 1s linear infinite; | ||||
| } | ||||
|  | ||||
| @keyframes spin { | ||||
|   0% { transform: rotate(0deg); } | ||||
|   100% { transform: rotate(360deg); } | ||||
| } | ||||
|  | ||||
| .loading-container p { | ||||
|   color: var(--color-text-muted); | ||||
|   margin: 0; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,9 +1,14 @@ | ||||
| <script setup> | ||||
| import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue' | ||||
| import Breadcrumbs from '@/components/Breadcrumbs.vue' | ||||
| import CredentialList from '@/components/CredentialList.vue' | ||||
| import UserBasicInfo from '@/components/UserBasicInfo.vue' | ||||
| import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' | ||||
| import StatusMessage from '@/components/StatusMessage.vue' | ||||
| import AdminOverview from './AdminOverview.vue' | ||||
| import AdminOrgDetail from './AdminOrgDetail.vue' | ||||
| import AdminUserDetail from './AdminUserDetail.vue' | ||||
| import AdminDialogs from './AdminDialogs.vue' | ||||
| import { useAuthStore } from '@/stores/auth' | ||||
|  | ||||
| const info = ref(null) | ||||
| @@ -19,15 +24,13 @@ const userLinkExpires = ref(null) | ||||
| const authStore = useAuthStore() | ||||
| const addingOrgForPermission = ref(null) | ||||
| const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$' | ||||
| const showCreatePermission = ref(false) | ||||
| const newPermId = ref('') | ||||
| const newPermName = ref('') | ||||
| const editingPermId = ref(null) | ||||
| const renameIdValue = ref('') | ||||
| const editingPermDisplay = ref(null) | ||||
| const renameDisplayValue = ref('') | ||||
| const dialog = ref({ type: null, data: null, busy: false, error: '' }) | ||||
| const safeIdRegex = /[^A-Za-z0-9:._~-]/g | ||||
|  | ||||
| function sanitizeNewId() { if (newPermId.value) newPermId.value = newPermId.value.replace(safeIdRegex, '') } | ||||
| function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') } | ||||
|  | ||||
| function handleGlobalClick(e) { | ||||
| @@ -51,7 +54,9 @@ const permissionSummary = computed(() => { | ||||
|   const summary = {} | ||||
|   for (const o of orgs.value) { | ||||
|     const orgBase = { uuid: o.uuid, display_name: o.display_name } | ||||
|     // Org-level permissions (direct) | ||||
|     const orgPerms = new Set(o.permissions || []) | ||||
|      | ||||
|     // Org-level permissions (direct) - only count if org can grant them | ||||
|     for (const pid of o.permissions || []) { | ||||
|       if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } | ||||
|       if (!summary[pid].orgSet.has(o.uuid)) { | ||||
| @@ -59,9 +64,13 @@ const permissionSummary = computed(() => { | ||||
|         summary[pid].orgSet.add(o.uuid) | ||||
|       } | ||||
|     } | ||||
|     // Role-based permissions (inheritance) | ||||
|      | ||||
|     // Role-based permissions (inheritance) - only count if org can grant them | ||||
|     for (const r of o.roles) { | ||||
|       for (const pid of r.permissions) { | ||||
|         // Only count if the org can grant this permission | ||||
|         if (!orgPerms.has(pid)) continue | ||||
|          | ||||
|         if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } | ||||
|         if (!summary[pid].orgSet.has(o.uuid)) { | ||||
|           summary[pid].orgs.push(orgBase) | ||||
| @@ -78,25 +87,7 @@ const permissionSummary = computed(() => { | ||||
|   return display | ||||
| }) | ||||
|  | ||||
| function availableOrgsForPermission(pid) { | ||||
|   return orgs.value.filter(o => !o.permissions.includes(pid)) | ||||
| } | ||||
|  | ||||
| function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p }) } | ||||
|  | ||||
| function startRenamePermissionId(p) { editingPermId.value = p.id; renameIdValue.value = p.id } | ||||
| function cancelRenameId() { editingPermId.value = null; renameIdValue.value = '' } | ||||
| async function submitRenamePermissionId(p) { | ||||
|   const newId = renameIdValue.value.trim() | ||||
|   if (!newId || newId === p.id) { cancelRenameId(); return } | ||||
|   try { | ||||
|     const body = { old_id: p.id, new_id: newId, display_name: p.display_name } | ||||
|     const res = await fetch('/auth/admin/permission/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) | ||||
|     let data; try { data = await res.json() } catch(_) { data = {} } | ||||
|     if (!res.ok || data.detail) throw new Error(data.detail || data.error || `Failed (${res.status})`) | ||||
|     await refreshPermissionsContext(); cancelRenameId() | ||||
|   } catch (e) { authStore.showMessage(e?.message || 'Rename failed') } | ||||
| } | ||||
| function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p, id: p.id, display_name: p.display_name }) } | ||||
|  | ||||
| async function refreshPermissionsContext() { | ||||
|   // Reload both lists so All Permissions table shows new associations promptly. | ||||
| @@ -179,6 +170,7 @@ async function load() { | ||||
|       if (!window.location.hash || window.location.hash === '#overview') { | ||||
|         currentOrgId.value = orgs.value[0].uuid | ||||
|         window.location.hash = `#org/${currentOrgId.value}` | ||||
|         authStore.showMessage(`Navigating to ${orgs.value[0].display_name} Administration`, 'info', 3000) | ||||
|       } else { | ||||
|         parseHash() | ||||
|       } | ||||
| @@ -193,14 +185,16 @@ async function load() { | ||||
| // Org actions | ||||
| function createOrg() { openDialog('org-create', {}) } | ||||
|  | ||||
| function updateOrg(org) { openDialog('org-update', { org }) } | ||||
| function updateOrg(org) { openDialog('org-update', { org, name: org.display_name }) } | ||||
|  | ||||
| function editUserName(user) { openDialog('user-update-name', { user, name: user.display_name }) } | ||||
|  | ||||
| function deleteOrg(org) { | ||||
|   if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return } | ||||
|   openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => { | ||||
|     const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' }) | ||||
|     const data = await res.json(); if (data.detail) throw new Error(data.detail) | ||||
|     await loadOrgs() | ||||
|     await Promise.all([loadOrgs(), loadPermissions()]) | ||||
|   } }) | ||||
| } | ||||
|  | ||||
| @@ -246,7 +240,7 @@ async function removeOrgPermission() { /* obsolete */ } | ||||
| // Role actions | ||||
| function createRole(org) { openDialog('role-create', { org }) } | ||||
|  | ||||
| function updateRole(role) { openDialog('role-update', { role }) } | ||||
| function updateRole(role) { openDialog('role-update', { role, name: role.display_name }) } | ||||
|  | ||||
| function deleteRole(role) { | ||||
|   openDialog('confirm', { message: `Delete role ${role.display_name}?`, action: async () => { | ||||
| @@ -256,17 +250,32 @@ function deleteRole(role) { | ||||
|   } }) | ||||
| } | ||||
|  | ||||
| // Permission actions | ||||
| async function submitCreatePermission() { | ||||
|   const id = newPermId.value.trim() | ||||
|   const name = newPermName.value.trim() | ||||
|   if (!id || !name) return | ||||
|   const res = await fetch('/auth/admin/permissions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, display_name: name }) }) | ||||
|   const data = await res.json(); if (data.detail) { authStore.showMessage(data.detail); return } | ||||
|   await loadPermissions(); newPermId.value=''; newPermName.value=''; showCreatePermission.value=false | ||||
| } | ||||
| function cancelCreatePermission() { newPermId.value=''; newPermName.value=''; showCreatePermission.value=false } | ||||
| async function toggleRolePermission(role, pid, checked) { | ||||
|   // Calculate new permissions array | ||||
|   const newPermissions = checked  | ||||
|     ? [...role.permissions, pid]  | ||||
|     : role.permissions.filter(p => p !== pid) | ||||
|    | ||||
|   // Optimistic update | ||||
|   const prevPermissions = [...role.permissions] | ||||
|   role.permissions = newPermissions | ||||
|    | ||||
|   try { | ||||
|     const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { | ||||
|       method: 'PUT', | ||||
|       headers: { 'content-type': 'application/json' }, | ||||
|       body: JSON.stringify({ display_name: role.display_name, permissions: newPermissions }) | ||||
|     }) | ||||
|     const data = await res.json() | ||||
|     if (data.detail) throw new Error(data.detail) | ||||
|     await loadOrgs() | ||||
|   } catch (e) { | ||||
|     authStore.showMessage(e.message || 'Failed to update role permission') | ||||
|     role.permissions = prevPermissions // revert | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Permission actions | ||||
| function updatePermission(p) { openDialog('perm-display', { permission: p }) } | ||||
|  | ||||
| function deletePermission(p) { | ||||
| @@ -313,9 +322,30 @@ const selectedUser = computed(() => { | ||||
| }) | ||||
|  | ||||
| const pageHeading = computed(() => { | ||||
|   if (selectedUser.value) return 'Organization Admin' | ||||
|   if (selectedOrg.value) return 'Organization Admin' | ||||
|   return (authStore.settings?.rp_name || 'Passkey') + ' Admin' | ||||
|   if (selectedUser.value) return 'Admin: User' | ||||
|   if (selectedOrg.value) return 'Admin: Org' | ||||
|   return (authStore.settings?.rp_name || 'Master') + ' Admin' | ||||
| }) | ||||
|  | ||||
| // Breadcrumb entries for admin app. | ||||
| const breadcrumbEntries = computed(() => { | ||||
|   const entries = [ | ||||
|     { label: 'Auth', href: authStore.uiHref() }, | ||||
|     { label: 'Admin', href: authStore.adminHomeHref() } | ||||
|   ] | ||||
|   // Determine organization for user view if selectedOrg not explicitly chosen. | ||||
|   let orgForUser = null | ||||
|   if (selectedUser.value) { | ||||
|     orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org_uuid) || null | ||||
|   } | ||||
|   const orgToShow = selectedOrg.value || orgForUser | ||||
|   if (orgToShow) { | ||||
|     entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` }) | ||||
|   } | ||||
|   if (selectedUser.value) { | ||||
|     entries.push({ label: selectedUser.value.display_name || 'User', href: `#user/${selectedUser.value.uuid}` }) | ||||
|   } | ||||
|   return entries | ||||
| }) | ||||
|  | ||||
| watch(selectedUser, async (u) => { | ||||
| @@ -349,26 +379,24 @@ function permissionDisplayName(id) { | ||||
|   return permissions.value.find(p => p.id === id)?.display_name || id | ||||
| } | ||||
|  | ||||
| async function toggleRolePermission(role, permId, checked) { | ||||
| async function toggleOrgPermission(org, permId, checked) { | ||||
|   // Build next permission list | ||||
|   const has = role.permissions.includes(permId) | ||||
|   const has = org.permissions.includes(permId) | ||||
|   if (checked && has) return | ||||
|   if (!checked && !has) return | ||||
|   const next = checked ? [...role.permissions, permId] : role.permissions.filter(p => p !== permId) | ||||
|   const next = checked ? [...org.permissions, permId] : org.permissions.filter(p => p !== permId) | ||||
|   // Optimistic update | ||||
|   const prev = [...role.permissions] | ||||
|   role.permissions = next | ||||
|   const prev = [...org.permissions] | ||||
|   org.permissions = next | ||||
|   try { | ||||
|   const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { | ||||
|       method: 'PUT', | ||||
|       headers: { 'content-type': 'application/json' }, | ||||
|       body: JSON.stringify({ display_name: role.display_name, permissions: next }) | ||||
|     }) | ||||
|     const params = new URLSearchParams({ permission_id: permId }) | ||||
|     const res = await fetch(`/auth/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' }) | ||||
|     const data = await res.json() | ||||
|     if (data.detail) throw new Error(data.detail) | ||||
|     await loadOrgs() | ||||
|   } catch (e) { | ||||
|     authStore.showMessage(e.message || 'Failed to update role permission') | ||||
|     role.permissions = prev // revert | ||||
|     authStore.showMessage(e.message || 'Failed to update organization permission') | ||||
|     org.permissions = prev // revert | ||||
|   } | ||||
| } | ||||
|  | ||||
| @@ -407,19 +435,42 @@ async function submitDialog() { | ||||
|       const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs() | ||||
|     } else if (t === 'role-update') { | ||||
|       const { role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required') | ||||
|       const permsCsv = dialog.value.data.perms || '' | ||||
|       const perms = permsCsv.split(',').map(s=>s.trim()).filter(Boolean) | ||||
|   const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: perms }) }) | ||||
|       const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: role.permissions }) }) | ||||
|       const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs() | ||||
|     } else if (t === 'user-create') { | ||||
|       const { org, role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required') | ||||
|       const res = await fetch(`/auth/admin/orgs/${org.uuid}/users`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, role: role.display_name }) }) | ||||
|       const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs() | ||||
|     } else if (t === 'user-update-name') { | ||||
|       const { user } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required') | ||||
|       const res = await fetch(`/auth/admin/orgs/${user.org_uuid}/users/${user.uuid}/display-name`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name }) }) | ||||
|       const d = await res.json(); if (d.detail) throw new Error(d.detail); await onUserNameSaved() | ||||
|     } else if (t === 'perm-display') { | ||||
|       const { permission } = dialog.value.data; const display = dialog.value.data.display_name?.trim(); if (!display) throw new Error('Display name required') | ||||
|       const params = new URLSearchParams({ permission_id: permission.id, display_name: display }) | ||||
|       const { permission } = dialog.value.data | ||||
|       const newId = dialog.value.data.id?.trim() | ||||
|       const newDisplay = dialog.value.data.display_name?.trim() | ||||
|       if (!newDisplay) throw new Error('Display name required') | ||||
|       if (!newId) throw new Error('ID required') | ||||
|        | ||||
|       if (newId !== permission.id) { | ||||
|         // ID changed, use rename endpoint | ||||
|         const body = { old_id: permission.id, new_id: newId, display_name: newDisplay } | ||||
|         const res = await fetch('/auth/admin/permission/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) | ||||
|         let data; try { data = await res.json() } catch(_) { data = {} } | ||||
|         if (!res.ok || data.detail) throw new Error(data.detail || data.error || `Failed (${res.status})`) | ||||
|       } else if (newDisplay !== permission.display_name) { | ||||
|         // Only display name changed | ||||
|         const params = new URLSearchParams({ permission_id: permission.id, display_name: newDisplay }) | ||||
|         const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'PUT' }) | ||||
|       const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadPermissions() | ||||
|         const d = await res.json(); if (d.detail) throw new Error(d.detail) | ||||
|       } | ||||
|       await loadPermissions() | ||||
|     } else if (t === 'perm-create') { | ||||
|       const id = dialog.value.data.id?.trim(); if (!id) throw new Error('ID required') | ||||
|       const display_name = dialog.value.data.display_name?.trim(); if (!display_name) throw new Error('Display name required') | ||||
|       const res = await fetch('/auth/admin/permissions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, display_name }) }) | ||||
|       const data = await res.json(); if (data.detail) throw new Error(data.detail) | ||||
|       await loadPermissions(); dialog.value.data.display_name = ''; dialog.value.data.id = '' | ||||
|     } else if (t === 'confirm') { | ||||
|       const action = dialog.value.data.action; if (action) await action() | ||||
|     } | ||||
| @@ -431,447 +482,94 @@ async function submitDialog() { | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div class="container"> | ||||
|     <h1> | ||||
|       {{ pageHeading }} | ||||
|       <a href="/auth/" class="back-link" title="Back to User App">User</a> | ||||
|       <a | ||||
|         v-if="info?.is_global_admin && (selectedOrg || selectedUser)" | ||||
|         @click.prevent="goOverview" | ||||
|         href="#overview" | ||||
|         class="nav-link" | ||||
|         title="Back to overview" | ||||
|       >Overview</a> | ||||
|     </h1> | ||||
|     <div v-if="loading">Loading…</div> | ||||
|     <div v-else-if="error" class="error">{{ error }}</div> | ||||
|     <div v-else> | ||||
|       <div v-if="!info?.authenticated"> | ||||
|   <div class="app-shell admin-shell"> | ||||
|     <StatusMessage /> | ||||
|     <main class="app-main"> | ||||
|       <section class="view-root view-admin"> | ||||
|         <div class="view-content view-content--wide"> | ||||
|           <header class="view-header"> | ||||
|             <h1>{{ pageHeading }}</h1> | ||||
|             <Breadcrumbs :entries="breadcrumbEntries" /> | ||||
|           </header> | ||||
|  | ||||
|           <section class="section-block admin-section"> | ||||
|             <div class="section-body admin-section-body"> | ||||
|               <div v-if="loading" class="surface surface--tight">Loading…</div> | ||||
|               <div v-else-if="error" class="surface surface--tight error">{{ error }}</div> | ||||
|               <template v-else> | ||||
|                 <div v-if="!info?.authenticated" class="surface surface--tight"> | ||||
|                   <p>You must be authenticated.</p> | ||||
|                 </div> | ||||
|       <div 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> | ||||
|                 </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" | ||||
|                   /> | ||||
|  | ||||
|   <!-- Removed user-specific info (current org, effective permissions, admin flags) --> | ||||
|  | ||||
|         <!-- Overview Page --> | ||||
|   <div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card"> | ||||
|           <h2>Organizations</h2> | ||||
|           <div class="actions"> | ||||
|             <button @click="createOrg" v-if="info.is_global_admin">+ Create Org</button> | ||||
|           </div> | ||||
|           <table class="org-table"> | ||||
|             <thead> | ||||
|               <tr> | ||||
|                 <th>Name</th> | ||||
|                 <th>Roles</th> | ||||
|                  <th>Members</th> | ||||
|                 <th v-if="info.is_global_admin">Actions</th> | ||||
|               </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|               <tr v-for="o in orgs" :key="o.uuid"> | ||||
|                 <td><a href="#org/{{o.uuid}}" @click.prevent="openOrg(o)">{{ o.display_name }}</a></td> | ||||
|                 <td>{{ o.roles.length }}</td> | ||||
|                 <td>{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td> | ||||
|                 <td v-if="info.is_global_admin"> | ||||
|                   <button @click="updateOrg(o)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button> | ||||
|                   <button @click="deleteOrg(o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization">❌</button> | ||||
|                 </td> | ||||
|               </tr> | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
|  | ||||
|         <!-- User Detail Page --> | ||||
|         <div v-if="selectedUser" class="card user-detail"> | ||||
|           <UserBasicInfo | ||||
|             v-if="userDetail && !userDetail.error" | ||||
|             :name="userDetail.display_name || selectedUser.display_name" | ||||
|             :visits="userDetail.visits" | ||||
|             :created-at="userDetail.created_at" | ||||
|             :last-seen="userDetail.last_seen" | ||||
|                   <AdminUserDetail | ||||
|                     v-else-if="selectedUser" | ||||
|                     :selected-user="selectedUser" | ||||
|                     :user-detail="userDetail" | ||||
|                     :selected-org="selectedOrg" | ||||
|                     :loading="loading" | ||||
|             :org-display-name="userDetail.org.display_name" | ||||
|             :role-name="userDetail.role" | ||||
|             :update-endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`" | ||||
|             @saved="onUserNameSaved" | ||||
|                     :show-reg-modal="showRegModal" | ||||
|                     @generate-user-registration-link="generateUserRegistrationLink" | ||||
|                     @go-overview="goOverview" | ||||
|                     @open-org="openOrg" | ||||
|                     @on-user-name-saved="onUserNameSaved" | ||||
|                     @edit-user-name="editUserName" | ||||
|                     @close-reg-modal="showRegModal = false" | ||||
|                   /> | ||||
|           <div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div> | ||||
|           <template v-if="userDetail && !userDetail.error"> | ||||
|             <h3 class="cred-title">Registered Passkeys</h3> | ||||
|             <CredentialList :credentials="userDetail.credentials" :aaguid-info="userDetail.aaguid_info" /> | ||||
|           </template> | ||||
|           <div class="actions"> | ||||
|             <button @click="generateUserRegistrationLink(selectedUser)">Generate Registration Token</button> | ||||
|             <button @click="goOverview" v-if="info.is_global_admin" class="icon-btn" title="Overview">🏠</button> | ||||
|             <button @click="openOrg(selectedOrg)" v-if="selectedOrg" class="icon-btn" title="Back to Org">↩️</button> | ||||
|           </div> | ||||
|           <p class="matrix-hint muted">Use the token dialog to register a new credential for the member.</p> | ||||
|           <RegistrationLinkModal | ||||
|             v-if="showRegModal" | ||||
|             :endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`" | ||||
|             :auto-copy="false" | ||||
|             @close="showRegModal = false" | ||||
|             @copied="onLinkCopied" | ||||
|                   <AdminOrgDetail | ||||
|                     v-else-if="selectedOrg" | ||||
|                     :selected-org="selectedOrg" | ||||
|                     :permissions="permissions" | ||||
|                     @update-org="updateOrg" | ||||
|                     @create-role="createRole" | ||||
|                     @update-role="updateRole" | ||||
|                     @delete-role="deleteRole" | ||||
|                     @create-user-in-role="createUserInRole" | ||||
|                     @open-user="openUser" | ||||
|                     @toggle-role-permission="toggleRolePermission" | ||||
|                     @on-role-drag-over="onRoleDragOver" | ||||
|                     @on-role-drop="onRoleDrop" | ||||
|                     @on-user-drag-start="onUserDragStart" | ||||
|                   /> | ||||
|  | ||||
|                 </div> | ||||
|  | ||||
|         <!-- Organization Detail Page --> | ||||
|         <div v-else-if="selectedOrg" class="card"> | ||||
|           <h2 class="org-title" :title="selectedOrg.uuid"> | ||||
|             <span class="org-name">{{ selectedOrg.display_name }}</span> | ||||
|             <button @click="updateOrg(selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button> | ||||
|           </h2> | ||||
|           <div class="org-actions"></div> | ||||
|  | ||||
|           <div class="matrix-wrapper"> | ||||
|             <div class="matrix-scroll"> | ||||
|               <div | ||||
|                 class="perm-matrix-grid" | ||||
|                 :style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + selectedOrg.roles.map(()=> '2.2rem').join(' ') + ' 2.2rem' }" | ||||
|               > | ||||
|                 <!-- Headers --> | ||||
|                 <div class="grid-head perm-head">Permission</div> | ||||
|                 <div | ||||
|                   v-for="r in selectedOrg.roles" | ||||
|                   :key="'head-' + r.uuid" | ||||
|                   class="grid-head role-head" | ||||
|                   :title="r.display_name" | ||||
|                 > | ||||
|                   <span>{{ r.display_name }}</span> | ||||
|               </template> | ||||
|             </div> | ||||
|                 <div class="grid-head role-head add-role-head" title="Add role" @click="createRole(selectedOrg)" role="button">➕</div> | ||||
|  | ||||
|                 <!-- Data Rows --> | ||||
|                 <template v-for="pid in selectedOrg.permissions" :key="pid"> | ||||
|                   <div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div> | ||||
|                   <div | ||||
|                     v-for="r in selectedOrg.roles" | ||||
|                     :key="r.uuid + '-' + pid" | ||||
|                     class="matrix-cell" | ||||
|                   > | ||||
|                     <input | ||||
|                       type="checkbox" | ||||
|                       :checked="r.permissions.includes(pid)" | ||||
|                       @change="e => toggleRolePermission(r, pid, e.target.checked)" | ||||
|           </section> | ||||
|         </div> | ||||
|       </section> | ||||
|     </main> | ||||
|     <AdminDialogs | ||||
|       :dialog="dialog" | ||||
|       :permission-id-pattern="PERMISSION_ID_PATTERN" | ||||
|       @submit-dialog="submitDialog" | ||||
|       @close-dialog="closeDialog" | ||||
|     /> | ||||
|   </div> | ||||
|                   <div class="matrix-cell add-role-cell" /> | ||||
|                 </template> | ||||
|               </div> | ||||
|             </div> | ||||
|             <p class="matrix-hint muted">Toggle which permissions each role grants.</p> | ||||
|           </div> | ||||
|           <div class="roles-grid"> | ||||
|             <div | ||||
|               v-for="r in selectedOrg.roles" | ||||
|               :key="r.uuid" | ||||
|               class="role-column" | ||||
|               @dragover="onRoleDragOver" | ||||
|               @drop="e => onRoleDrop(e, selectedOrg, r)" | ||||
|             > | ||||
|               <div class="role-header"> | ||||
|                 <strong class="role-name" :title="r.uuid"> | ||||
|                   <span>{{ r.display_name }}</span> | ||||
|                   <button @click="updateRole(r)" class="icon-btn" aria-label="Edit role" title="Edit role">✏️</button> | ||||
|                 </strong> | ||||
|                 <div class="role-actions"> | ||||
|                   <button @click="createUserInRole(selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user">➕</button> | ||||
|                 </div> | ||||
|               </div> | ||||
|               <template v-if="r.users.length > 0"> | ||||
|         <ul class="user-list"> | ||||
|                   <li | ||||
|                     v-for="u in r.users" | ||||
|                     :key="u.uuid" | ||||
|                     class="user-chip" | ||||
|                     draggable="true" | ||||
|                     @dragstart="e => onUserDragStart(e, u, selectedOrg.uuid)" | ||||
|           @click="openUser(u)" | ||||
|                     :title="u.uuid" | ||||
|                   > | ||||
|                     <span class="name">{{ u.display_name }}</span> | ||||
|                     <span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span> | ||||
|                   </li> | ||||
|                 </ul> | ||||
|               </template> | ||||
|               <div v-else class="empty-role"> | ||||
|                 <p class="empty-text muted">No members</p> | ||||
|                 <button @click="deleteRole(r)" class="icon-btn delete-icon" aria-label="Delete empty role" title="Delete role">❌</button> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|   <div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card"> | ||||
|           <h2>All Permissions</h2> | ||||
|           <div class="actions"> | ||||
|             <button v-if="!showCreatePermission" @click="showCreatePermission = true">+ Create Permission</button> | ||||
|             <form v-else class="inline-form" @submit.prevent="submitCreatePermission"> | ||||
|               <input v-model="newPermId" @input="sanitizeNewId" required :pattern="PERMISSION_ID_PATTERN" placeholder="permission id" title="Allowed: A-Za-z0-9:._~-" /> | ||||
|               <input v-model="newPermName" required placeholder="display name" /> | ||||
|               <button type="submit">Save</button> | ||||
|               <button type="button" @click="cancelCreatePermission">Cancel</button> | ||||
|             </form> | ||||
|           </div> | ||||
|           <div class="permission-grid"> | ||||
|             <div class="perm-grid-head">Permission</div> | ||||
|             <div class="perm-grid-head">Orgs</div> | ||||
|             <div class="perm-grid-head center">Members</div> | ||||
|             <div class="perm-grid-head center">Actions</div> | ||||
|             <template v-for="p in [...permissions].sort((a,b)=> a.id.localeCompare(b.id))" :key="p.id"> | ||||
|               <div class="perm-cell perm-name" :title="p.id"> | ||||
|                 <div class="perm-title-line">{{ p.display_name }}</div> | ||||
|                 <div class="perm-id-line muted">{{ p.id }}</div> | ||||
|               </div> | ||||
|               <div class="perm-cell perm-orgs" :title="permissionSummary[p.id]?.orgs?.map(o=>o.display_name).join(', ') || ''"> | ||||
|                 <template v-if="permissionSummary[p.id]"> | ||||
|                   <span class="org-pill" v-for="o in permissionSummary[p.id].orgs" :key="o.uuid"> | ||||
|                     {{ o.display_name }} | ||||
|                     <button class="pill-x" @click.stop="detachPermissionFromOrg(p.id, o.uuid)" aria-label="Remove">×</button> | ||||
|                   </span> | ||||
|                 </template> | ||||
|                 <span class="org-add-wrapper"> | ||||
|                   <button | ||||
|                     v-if="availableOrgsForPermission(p.id).length && addingOrgForPermission !== p.id" | ||||
|                     class="add-org-btn" | ||||
|                     @click.stop="addingOrgForPermission = p.id" | ||||
|                     aria-label="Add organization" | ||||
|                     title="Add organization" | ||||
|                   >➕</button> | ||||
|                   <div | ||||
|                     v-if="addingOrgForPermission === p.id" | ||||
|                     class="org-add-menu" | ||||
|                     tabindex="0" | ||||
|                     @keydown.escape.stop.prevent="addingOrgForPermission = null" | ||||
|                   > | ||||
|                     <div class="org-add-list"> | ||||
|                       <button | ||||
|                         v-for="o in availableOrgsForPermission(p.id)" | ||||
|                         :key="o.uuid" | ||||
|                         class="org-add-item" | ||||
|                         @click.stop="attachPermissionToOrg(p.id, o.uuid); addingOrgForPermission = null" | ||||
|                       >{{ o.display_name }}</button> | ||||
|                     </div> | ||||
|                     <div class="org-add-footer"> | ||||
|                       <button class="org-add-cancel" @click.stop="addingOrgForPermission = null" aria-label="Cancel">Cancel</button> | ||||
|                     </div> | ||||
|                   </div> | ||||
|                 </span> | ||||
|               </div> | ||||
|               <div class="perm-cell perm-users center">{{ permissionSummary[p.id]?.userCount || 0 }}</div> | ||||
|               <div class="perm-cell perm-actions center"> | ||||
|                 <div class="perm-actions-inner" :class="{ editing: editingPermId === p.id }"> | ||||
|                   <div class="actions-view"> | ||||
|                     <button @click="renamePermissionDisplay(p)" class="icon-btn" aria-label="Change display name" title="Change display name">✏️</button> | ||||
|                     <button @click="startRenamePermissionId(p)" class="icon-btn" aria-label="Change id" title="Change id">🆔</button> | ||||
|                     <button @click="deletePermission(p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission">❌</button> | ||||
|                   </div> | ||||
|                   <form class="inline-id-form overlay" @submit.prevent="submitRenamePermissionId(p)"> | ||||
|                     <input v-model="renameIdValue" @input="sanitizeRenameId" required :pattern="PERMISSION_ID_PATTERN" class="id-input" title="Allowed: A-Za-z0-9:._~-" /> | ||||
|                     <button type="submit" class="icon-btn" aria-label="Save">✔</button> | ||||
|                     <button type="button" class="icon-btn" @click="cancelRenameId" aria-label="Cancel">✖</button> | ||||
|                   </form> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </template> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
|   <StatusMessage /> | ||||
|   <div v-if="dialog.type" class="modal-overlay" @keydown.esc.prevent.stop="closeDialog" tabindex="-1"> | ||||
|     <div class="modal" role="dialog" aria-modal="true"> | ||||
|       <h3 class="modal-title"> | ||||
|         <template v-if="dialog.type==='org-create'">Create Organization</template> | ||||
|         <template v-else-if="dialog.type==='org-update'">Rename Organization</template> | ||||
|         <template v-else-if="dialog.type==='role-create'">Create Role</template> | ||||
|         <template v-else-if="dialog.type==='role-update'">Edit Role</template> | ||||
|         <template v-else-if="dialog.type==='user-create'">Add User To Role</template> | ||||
|         <template v-else-if="dialog.type==='perm-display'">Edit Permission Display</template> | ||||
|         <template v-else-if="dialog.type==='confirm'">Confirm</template> | ||||
|       </h3> | ||||
|       <form @submit.prevent="submitDialog" class="modal-form"> | ||||
|         <template v-if="dialog.type==='org-create' || dialog.type==='org-update'"> | ||||
|           <label>Name | ||||
|             <input v-model="dialog.data.name" :placeholder="dialog.type==='org-update'? dialog.data.org.display_name : 'Organization name'" required /> | ||||
|           </label> | ||||
|         </template> | ||||
|         <template v-else-if="dialog.type==='role-create'"> | ||||
|           <label>Role Name | ||||
|             <input v-model="dialog.data.name" placeholder="Role name" required /> | ||||
|           </label> | ||||
|         </template> | ||||
|         <template v-else-if="dialog.type==='role-update'"> | ||||
|           <label>Role Name | ||||
|             <input v-model="dialog.data.name" :placeholder="dialog.data.role.display_name" required /> | ||||
|           </label> | ||||
|           <label>Permissions (comma separated) | ||||
|             <textarea v-model="dialog.data.perms" rows="2" placeholder="perm:a, perm:b"></textarea> | ||||
|           </label> | ||||
|         </template> | ||||
|         <template v-else-if="dialog.type==='user-create'"> | ||||
|           <p class="small muted">Role: {{ dialog.data.role.display_name }}</p> | ||||
|           <label>Display Name | ||||
|             <input v-model="dialog.data.name" placeholder="User display name" required /> | ||||
|           </label> | ||||
|         </template> | ||||
|         <template v-else-if="dialog.type==='perm-display'"> | ||||
|           <p class="small muted">ID: {{ dialog.data.permission.id }}</p> | ||||
|             <label>Display Name | ||||
|               <input v-model="dialog.data.display_name" :placeholder="dialog.data.permission.display_name" required /> | ||||
|             </label> | ||||
|         </template> | ||||
|         <template v-else-if="dialog.type==='confirm'"> | ||||
|           <p>{{ dialog.data.message }}</p> | ||||
|         </template> | ||||
|         <div v-if="dialog.error" class="error small">{{ dialog.error }}</div> | ||||
|         <div class="modal-actions"> | ||||
|           <button type="submit" :disabled="dialog.busy">{{ dialog.type==='confirm' ? 'OK' : 'Save' }}</button> | ||||
|           <button type="button" @click="closeDialog" :disabled="dialog.busy">Cancel</button> | ||||
|         </div> | ||||
|       </form> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped> | ||||
| .container { max-width: 960px; margin: 2rem auto; padding: 0 1rem; } | ||||
| .subtitle { color: #888 } | ||||
| .card { margin: 1rem 0; padding: 1rem; border: 1px solid #eee; border-radius: 8px; } | ||||
| .error { color: #a00 } | ||||
| .actions { margin-bottom: .5rem } | ||||
| .org { border-top: 1px dashed #eee; padding: .5rem 0 } | ||||
| .org-header { display: flex; gap: .5rem; align-items: baseline } | ||||
| .user-item { display: flex; gap: .5rem; margin: .15rem 0 } | ||||
| .users-table { width: 100%; border-collapse: collapse; margin-top: .25rem; } | ||||
| .users-table th, .users-table td { padding: .25rem .4rem; text-align: left; border-bottom: 1px solid #eee; font-weight: normal; } | ||||
| .users-table th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: #555; } | ||||
| .users-table tbody tr:hover { background: #fafafa; } | ||||
| .org-actions, .role-actions, .perm-actions { display: flex; gap: .5rem; margin: .25rem 0 } | ||||
| .muted { color: #666 } | ||||
| .small { font-size: .9em } | ||||
| .pill-list { display: flex; flex-wrap: wrap; gap: .25rem } | ||||
| .pill { background: #f3f3f3; border: 1px solid #e2e2e2; border-radius: 999px; padding: .1rem .5rem; display: inline-flex; align-items: center; gap: .25rem } | ||||
| .pill-x { background: transparent; border: none; color: #900; cursor: pointer } | ||||
| button { padding: .25rem .5rem; border-radius: 6px; border: 1px solid #ddd; background: #fff; cursor: pointer } | ||||
| button:hover { background: #f7f7f7 } | ||||
| /* Avoid global button 100% width from frontend main styles */ | ||||
| button, .perm-actions button, .org-actions button, .role-actions button { width: auto; } | ||||
| .roles-grid { display: flex; flex-wrap: wrap; gap: 1rem; align-items: stretch; padding: .5rem 0; } | ||||
| .role-column { background: #fafafa; border: 1px solid #eee; border-radius: 8px; padding: .5rem; min-width: 200px; flex: 1 1 240px; display: flex; flex-direction: column; max-width: 300px; } | ||||
| .role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .25rem } | ||||
| .user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .25rem; flex: 1 1 auto; } | ||||
| .user-chip { background: #fff; border: 1px solid #ddd; border-radius: 6px; padding: .25rem .4rem; display: flex; justify-content: space-between; gap: .5rem; cursor: grab; } | ||||
| .user-chip:active { cursor: grabbing } | ||||
| .user-chip .name { font-weight: 500 } | ||||
| .user-chip .meta { font-size: .65rem; color: #666 } | ||||
| .role-column.drag-over { outline: 2px dashed #66a; } | ||||
| .org-table { width: 100%; border-collapse: collapse; } | ||||
| .org-table th, .org-table td { padding: .4rem .5rem; border-bottom: 1px solid #eee; text-align: left; } | ||||
| .org-table th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: #555; } | ||||
| .org-table a { text-decoration: none; color: #0366d6; } | ||||
| .org-table a:hover { text-decoration: underline; } | ||||
| .nav-link { font-size: .6em; margin-left: .5rem; background: #eee; padding: .25em .6em; border-radius: 999px; border: 1px solid #ccc; text-decoration: none; } | ||||
| .nav-link:hover { background: #ddd; } | ||||
| .back-link { font-size: .5em; margin-left: .75rem; text-decoration: none; background: #eee; padding: .25em .6em; border-radius: 999px; border: 1px solid #ccc; vertical-align: middle; line-height: 1.2; } | ||||
| .back-link:hover { background: #ddd; } | ||||
| .matrix-wrapper { margin: 1rem 0; text-align: left; } | ||||
| .matrix-scroll { overflow-x: auto; text-align: left; } | ||||
| .perm-matrix-grid { display: inline-grid; gap: 0; align-items: stretch; margin-right: 4rem; } | ||||
| .perm-matrix-grid > * { background: #fff; border: none; padding: .35rem .4rem; font-size: .75rem; } | ||||
| .perm-matrix-grid .grid-head { background: transparent; border: none; font-size: .65rem; letter-spacing: .05em; font-weight: 600; text-transform: uppercase; display: flex; justify-content: center; align-items: flex-end; padding-bottom: .25rem; } | ||||
| .perm-matrix-grid .perm-head { justify-content: flex-start; align-items: flex-end; } | ||||
| .perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: .6rem; line-height: 1; } | ||||
| .perm-matrix-grid .perm-name { font-weight: 500; white-space: nowrap; text-align: left; } | ||||
| .perm-matrix-grid .matrix-cell { display: flex; justify-content: center; align-items: center; } | ||||
| .perm-matrix-grid .matrix-cell input { cursor: pointer; } | ||||
| .matrix-hint { font-size: .7rem; margin-top: .25rem; } | ||||
| /* Add role column styles */ | ||||
| .add-role-head { cursor: pointer; color: #2a6; font-size: 1rem; display:flex; justify-content:center; align-items:flex-end; } | ||||
| .add-role-head:hover { color:#1c4; } | ||||
| /* Removed add-role placeholder styles */ | ||||
| /* Inline organization title with icon */ | ||||
| .org-title { display: flex; align-items: center; gap: .4rem; } | ||||
| .org-title .org-name { flex: 0 1 auto; } | ||||
| /* Plus button for adding users */ | ||||
| .plus-btn { background: none; border: none; font-size: 1.15rem; line-height: 1; padding: 0 .1rem; cursor: pointer; opacity: .6; } | ||||
| .plus-btn:hover, .plus-btn:focus { opacity: 1; outline: none; } | ||||
| .plus-btn:focus-visible { outline: 2px solid #555; outline-offset: 2px; } | ||||
| .empty-role { display: flex; flex-direction: column; gap: .4rem; align-items: flex-start; padding: .35rem .25rem; /* removed flex grow & width for natural size */ } | ||||
| .empty-role .empty-text { font-size: .7rem; margin: 0; } | ||||
| .delete-icon { color: #c00; } | ||||
| .delete-icon:hover, .delete-icon:focus { color: #ff0000; } | ||||
| .user-detail .user-link-box { margin-top: .75rem; font-size: .7rem; background: #fff; border: 1px dashed #ccc; padding: .5rem; border-radius: 6px; cursor: pointer; word-break: break-all; } | ||||
| .user-detail .user-link-box:hover { background: #f9f9f9; } | ||||
| .user-detail .user-link-box .expires { font-size: .6rem; margin-top: .25rem; color: #555; } | ||||
| /* Minimal icon button for rename/edit actions */ | ||||
| .icon-btn { background: none; border: none; padding: 0 .15rem; margin-left: .15rem; cursor: pointer; font-size: .8rem; line-height: 1; opacity: .55; vertical-align: middle; } | ||||
| .icon-btn:hover, .icon-btn:focus { opacity: .95; outline: none; } | ||||
| .icon-btn:focus-visible { outline: 2px solid #555; outline-offset: 2px; } | ||||
| .icon-btn:active { transform: translateY(1px); } | ||||
| .org-title { display: flex; align-items: baseline; gap: .25rem; } | ||||
| .role-name { display: inline-flex; align-items: center; gap: .15rem; font-weight: 600; } | ||||
| .perm-name-line { display: flex; align-items: center; gap: .15rem; } | ||||
| .user-meta { margin-top: .25rem; } | ||||
| .cred-title { margin-top: .75rem; font-size: .85rem; } | ||||
| .cred-list { list-style: none; padding: 0; margin: .25rem 0 .5rem; display: flex; flex-direction: column; gap: .35rem; } | ||||
| .cred-item { background: #fff; border: 1px solid #eee; border-radius: 6px; padding: .35rem .5rem; font-size: .65rem; } | ||||
| .cred-line { display: flex; flex-direction: column; gap: .15rem; } | ||||
| .cred-line .dates { color: #555; font-size: .6rem; } | ||||
| /* Permission grid */ | ||||
| .permission-grid { display: grid; grid-template-columns: minmax(220px,2fr) minmax(160px,3fr) 70px 90px; gap: 2px; margin-top: .5rem; } | ||||
| .permission-grid .perm-grid-head { font-size: .6rem; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; padding: .35rem .4rem; background: #f3f3f3; border: 1px solid #e1e1e1; } | ||||
| .permission-grid .perm-cell { background: #fff; border: 1px solid #eee; padding: .35rem .4rem; font-size: .7rem; display: flex; align-items: center; gap: .4rem; } | ||||
| .permission-grid .perm-name { flex-direction: row; flex-wrap: wrap; } | ||||
| .permission-grid .perm-name { flex-direction: column; align-items: flex-start; gap:2px; } | ||||
| .permission-grid .perm-title-line { font-weight:600; line-height:1.1; } | ||||
| .permission-grid .perm-id-line { font-size:.55rem; line-height:1.1; word-break:break-all; } | ||||
| .permission-grid .center { justify-content: center; } | ||||
| .permission-grid .perm-actions { gap: .25rem; } | ||||
| .permission-grid .perm-actions .icon-btn { font-size: .9rem; } | ||||
| /* Inline edit overlay to avoid layout shift */ | ||||
| .perm-actions-inner { position: relative; display:flex; width:100%; justify-content:center; } | ||||
| .perm-actions-inner .inline-id-form.overlay { position:absolute; inset:0; display:none; align-items:center; justify-content:center; gap:.25rem; background:rgba(255,255,255,.9); backdrop-filter:blur(2px); padding:0 .15rem; } | ||||
| .perm-actions-inner.editing .inline-id-form.overlay { display:inline-flex; } | ||||
| .perm-actions-inner.editing .actions-view { visibility:hidden; } | ||||
| /* Inline forms */ | ||||
| .inline-form, .inline-id-form { display:inline-flex; gap:.25rem; align-items:center; } | ||||
| .inline-form input, .inline-id-form input { padding:.25rem .4rem; font-size:.6rem; border:1px solid #ccc; border-radius:4px; } | ||||
| .inline-form button, .inline-id-form button { font-size:.6rem; padding:.3rem .5rem; } | ||||
| .inline-id-form .id-input { width:120px; } | ||||
| /* Modal */ | ||||
| .modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.4); display:flex; justify-content:center; align-items:flex-start; padding-top:8vh; z-index:200; } | ||||
| .modal { background:#fff; border-radius:10px; padding:1rem 1.1rem; width: min(420px, 90%); box-shadow:0 10px 30px rgba(0,0,0,.25); animation:pop .18s ease; } | ||||
| @keyframes pop { from { transform:translateY(10px); opacity:0 } to { transform:translateY(0); opacity:1 } } | ||||
| .modal-title { margin:0 0 .65rem; font-size:1rem; } | ||||
| .modal-form { display:flex; flex-direction:column; gap:.65rem; } | ||||
| .modal-form label { display:flex; flex-direction:column; font-size:.65rem; gap:.25rem; font-weight:600; } | ||||
| .modal-form input, .modal-form textarea { border:1px solid #ccc; border-radius:6px; padding:.45rem .55rem; font-size:.7rem; font-weight:400; font-family:inherit; } | ||||
| .modal-form textarea { resize:vertical; } | ||||
| .modal-actions { display:flex; gap:.5rem; justify-content:flex-end; margin-top:.25rem; } | ||||
| .modal-actions button { font-size:.65rem; } | ||||
| /* Org pill editing */ | ||||
| .perm-orgs { flex-wrap: wrap; gap: .25rem; } | ||||
| .perm-orgs .org-pill { background:#eef4ff; border:1px solid #d0dcf0; padding:2px 6px; border-radius:999px; font-size:.55rem; display:inline-flex; align-items:center; gap:4px; } | ||||
| .perm-orgs .org-pill .pill-x { background:none; border:none; cursor:pointer; font-size:.7rem; line-height:1; padding:0; margin:0; color:#555; } | ||||
| .perm-orgs .org-pill .pill-x:hover { color:#c00; } | ||||
| .add-org-btn { background:none; border:none; cursor:pointer; font-size:.7rem; padding:0 2px; line-height:1; opacity:.55; display:inline; } | ||||
| .add-org-btn:hover, .add-org-btn:focus { opacity:1; } | ||||
| .add-org-btn:focus-visible { outline:2px solid #555; outline-offset:2px; } | ||||
| .org-add-wrapper { position:relative; display:inline-block; } | ||||
| .org-add-menu { position:absolute; top:100%; left:0; z-index:20; margin-top:4px; min-width:160px; background:#fff; border:1px solid #e2e6ea; border-radius:6px; padding:.3rem .35rem; box-shadow:0 4px 10px rgba(0,0,0,.08); display:flex; flex-direction:column; gap:.25rem; font-size:.6rem; } | ||||
| .org-add-menu:before { content:""; position:absolute; top:-5px; left:10px; width:8px; height:8px; background:#fff; border-left:1px solid #e2e6ea; border-top:1px solid #e2e6ea; transform:rotate(45deg); } | ||||
| .org-add-list { display:flex; flex-direction:column; gap:0; max-height:180px; overflow-y:auto; scrollbar-width:thin; } | ||||
| .org-add-item { background:transparent; border:none; padding:.25rem .4rem; font-size:.6rem; border-radius:4px; cursor:pointer; line-height:1.1; text-align:left; width:100%; color:#222; } | ||||
| .org-add-item:hover, .org-add-item:focus { background:#f2f5f9; } | ||||
| .org-add-item:active { background:#e6ebf0; } | ||||
| .org-add-footer { margin-top:.25rem; display:flex; justify-content:flex-end; } | ||||
| .org-add-cancel { background:transparent; border:none; font-size:.55rem; padding:.15rem .35rem; cursor:pointer; color:#666; border-radius:4px; } | ||||
| .org-add-cancel:hover, .org-add-cancel:focus { background:#f2f5f9; color:#222; } | ||||
| .org-add-cancel:active { background:#e6ebf0; } | ||||
| .view-admin { padding-bottom: var(--space-3xl); } | ||||
| .view-header { display: flex; flex-direction: column; gap: var(--space-sm); } | ||||
| .admin-section { margin-top: var(--space-xl); } | ||||
| .admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); } | ||||
| .admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); } | ||||
| </style> | ||||
|   | ||||
							
								
								
									
										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> | ||||
							
								
								
									
										89
									
								
								frontend/src/admin/AdminUserDetail.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								frontend/src/admin/AdminUserDetail.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | ||||
| <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 { 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"> | ||||
|       <h3 class="cred-title">Registered Passkeys</h3> | ||||
|       <CredentialList :credentials="userDetail.credentials" :aaguid-info="userDetail.aaguid_info" :allow-delete="true" @delete="handleDelete" /> | ||||
|     </template> | ||||
|     <div class="actions"> | ||||
|       <button @click="$emit('generateUserRegistrationLink', selectedUser)">Generate Registration Token</button> | ||||
|       <button v-if="selectedOrg" @click="$emit('openOrg', selectedOrg)" class="icon-btn" title="Back to Org">↩️</button> | ||||
|     </div> | ||||
|     <p class="matrix-hint muted">Use the token dialog to register a new credential for the member.</p> | ||||
|     <RegistrationLinkModal | ||||
|       v-if="showRegModal" | ||||
|       :endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`" | ||||
|       :auto-copy="false" | ||||
|       @close="$emit('closeRegModal')" | ||||
|       @copied="onLinkCopied" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped> | ||||
| .user-detail { display: flex; flex-direction: column; gap: var(--space-lg); } | ||||
| .cred-title { font-size: 1.25rem; font-weight: 600; color: var(--color-heading); margin-bottom: var(--space-md); } | ||||
| .actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; } | ||||
| .actions button { width: auto; } | ||||
| .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
											
										
									
								
							
							
								
								
									
										38
									
								
								frontend/src/components/Breadcrumbs.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								frontend/src/components/Breadcrumbs.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| <script setup> | ||||
| import { computed } from 'vue' | ||||
|  | ||||
| // Props: | ||||
| // entries: Array<{ label:string, href:string }> | ||||
| // showHome: include leading home icon (defaults true) | ||||
| // homeHref: home link target (default '/') | ||||
| const props = defineProps({ | ||||
|   entries: { type: Array, default: () => [] }, | ||||
|   showHome: { type: Boolean, default: true }, | ||||
|   homeHref: { type: String, default: '/' } | ||||
| }) | ||||
|  | ||||
| const crumbs = computed(() => { | ||||
|   const base = props.showHome ? [{ label: '🏠', href: props.homeHref }] : [] | ||||
|   return [...base, ...props.entries] | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <nav class="breadcrumbs" aria-label="Breadcrumb" v-if="crumbs.length"> | ||||
|     <ol> | ||||
|       <li v-for="(c, idx) in crumbs" :key="idx"> | ||||
|         <a :href="c.href">{{ c.label }}</a> | ||||
|         <span v-if="idx < crumbs.length - 1" class="sep"> — </span> | ||||
|       </li> | ||||
|     </ol> | ||||
|   </nav> | ||||
| </template> | ||||
|  | ||||
| <style scoped> | ||||
| .breadcrumbs { margin: .25rem 0 .5rem; line-height:1.2; color: var(--color-text-muted); } | ||||
| .breadcrumbs ol { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: .25rem; } | ||||
| .breadcrumbs li { display: inline-flex; align-items: center; gap: .25rem; font-size: .9rem; } | ||||
| .breadcrumbs a { text-decoration: none; color: var(--color-link); padding: 0 .25rem; border-radius:4px; transition: color 0.2s ease, background 0.2s ease; } | ||||
| .breadcrumbs a:hover, .breadcrumbs a:focus-visible { text-decoration: underline; color: var(--color-link-hover); outline: none; } | ||||
| .breadcrumbs .sep { color: var(--color-text-muted); margin: 0; } | ||||
| </style> | ||||
| @@ -2,7 +2,7 @@ | ||||
|   <div class="credential-list"> | ||||
|     <div v-if="loading"><p>Loading credentials...</p></div> | ||||
|     <div v-else-if="!credentials?.length"><p>No passkeys found.</p></div> | ||||
|     <div v-else> | ||||
|     <template v-else> | ||||
|       <div | ||||
|         v-for="credential in credentials" | ||||
|         :key="credential.credential_uuid" | ||||
| @@ -39,7 +39,7 @@ | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     </template> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| @@ -69,16 +69,119 @@ const getCredentialAuthIcon = (credential) => { | ||||
| </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; } | ||||
| .credential-list { | ||||
|   width: 100%; | ||||
|   margin-top: var(--space-sm); | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); | ||||
|   gap: 1rem 1.25rem; | ||||
|   align-items: stretch; | ||||
| } | ||||
|  | ||||
| .credential-item { | ||||
|   border: 1px solid var(--color-border); | ||||
|   border-radius: var(--radius-sm); | ||||
|   padding: 0.85rem 1rem; | ||||
|   background: var(--color-surface); | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 0.75rem; | ||||
|   width: 28rem; | ||||
|   height: 100%; | ||||
|   transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; | ||||
| } | ||||
|  | ||||
| .credential-item:hover { | ||||
|   border-color: var(--color-border-strong); | ||||
|   box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12); | ||||
|   transform: translateY(-1px); | ||||
| } | ||||
|  | ||||
| .credential-item.current-session { | ||||
|   border-color: var(--color-accent); | ||||
|   background: rgba(37, 99, 235, 0.08); | ||||
| } | ||||
|  | ||||
| .credential-header { | ||||
|   display: flex; | ||||
|   align-items: flex-start; | ||||
|   gap: 1rem; | ||||
|   flex-wrap: wrap; | ||||
|   flex: 1 1 auto; | ||||
| } | ||||
|  | ||||
| .credential-icon { | ||||
|   width: 40px; | ||||
|   height: 40px; | ||||
|   display: grid; | ||||
|   place-items: center; | ||||
|   background: var(--color-surface-subtle, transparent); | ||||
|   border-radius: var(--radius-sm); | ||||
|   border: 1px solid var(--color-border); | ||||
| } | ||||
|  | ||||
| .auth-icon { | ||||
|   border-radius: var(--radius-sm); | ||||
| } | ||||
|  | ||||
| .credential-info { | ||||
|   flex: 1 1 150px; | ||||
|   min-width: 0; | ||||
| } | ||||
|  | ||||
| .credential-info h4 { | ||||
|   margin: 0; | ||||
|   font-size: 1rem; | ||||
|   font-weight: 600; | ||||
|   color: var(--color-heading); | ||||
| } | ||||
|  | ||||
| .credential-dates { | ||||
|   display: grid; | ||||
|   grid-auto-flow: row; | ||||
|   grid-template-columns: auto 1fr; | ||||
|   gap: 0.35rem 0.5rem; | ||||
|   font-size: 0.75rem; | ||||
|   align-items: center; | ||||
|   color: var(--color-text-muted); | ||||
| } | ||||
|  | ||||
| .date-label { | ||||
|   font-weight: 600; | ||||
| } | ||||
|  | ||||
| .date-value { | ||||
|   color: var(--color-text); | ||||
| } | ||||
|  | ||||
| .credential-actions { | ||||
|   margin-left: auto; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
| .btn-delete-credential { | ||||
|   background: none; | ||||
|   border: none; | ||||
|   cursor: pointer; | ||||
|   font-size: 1rem; | ||||
|   color: var(--color-danger); | ||||
|   padding: 0.25rem 0.35rem; | ||||
|   border-radius: var(--radius-sm); | ||||
| } | ||||
|  | ||||
| .btn-delete-credential:hover:not(:disabled) { | ||||
|   background: rgba(220, 38, 38, 0.08); | ||||
| } | ||||
|  | ||||
| .btn-delete-credential:disabled { | ||||
|   opacity: 0.35; | ||||
|   cursor: not-allowed; | ||||
| } | ||||
|  | ||||
| @media (max-width: 600px) { | ||||
|   .credential-list { | ||||
|     grid-template-columns: 1fr; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,11 +1,16 @@ | ||||
| <template> | ||||
|   <div class="container"> | ||||
|     <div class="view active"> | ||||
|   <section class="view-root view-device-link"> | ||||
|     <div class="view-content view-content--narrow"> | ||||
|       <header class="view-header"> | ||||
|         <h1>📱 Add Another Device</h1> | ||||
|         <p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p> | ||||
|       </header> | ||||
|       <section class="section-block"> | ||||
|         <div class="section-body"> | ||||
|           <div class="device-link-section"> | ||||
|             <div class="qr-container"> | ||||
|           <a :href="url" id="deviceLinkText" @click="copyLink"> | ||||
|             <canvas id="qrCode" class="qr-code"></canvas> | ||||
|               <a :href="url" class="qr-link" @click="copyLink"> | ||||
|                 <canvas ref="qrCanvas" class="qr-code"></canvas> | ||||
|                 <p v-if="url"> | ||||
|                   {{ url.replace(/^[^:]+:\/\//, '') }} | ||||
|                 </p> | ||||
| @@ -19,21 +24,25 @@ | ||||
|               </p> | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|           <div class="button-row"> | ||||
|             <button @click="authStore.currentView = 'profile'" class="btn-secondary"> | ||||
|               Back to Profile | ||||
|             </button> | ||||
|           </div> | ||||
|         </div> | ||||
|       </section> | ||||
|     </div> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, onMounted } from 'vue' | ||||
| import { ref, onMounted, nextTick } from 'vue' | ||||
| import { useAuthStore } from '@/stores/auth' | ||||
| import QRCode from 'qrcode/lib/browser' | ||||
|  | ||||
| const authStore = useAuthStore() | ||||
| const url = ref(null) | ||||
| const qrCanvas = ref(null) | ||||
|  | ||||
| const copyLink = async (event) => { | ||||
|   event.preventDefault() | ||||
| @@ -44,6 +53,14 @@ const copyLink = async (event) => { | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function drawQr() { | ||||
|   if (!url.value || !qrCanvas.value) return | ||||
|   await nextTick() | ||||
|   QRCode.toCanvas(qrCanvas.value, url.value, { scale: 8 }, (error) => { | ||||
|     if (error) console.error('Failed to generate QR code:', error) | ||||
|   }) | ||||
| } | ||||
|  | ||||
| onMounted(async () => { | ||||
|   try { | ||||
|     const response = await fetch('/auth/api/create-link', { method: 'POST' }) | ||||
| @@ -51,17 +68,41 @@ onMounted(async () => { | ||||
|     if (result.detail) throw new Error(result.detail) | ||||
|  | ||||
|     url.value = result.url | ||||
|  | ||||
|     // Generate QR code | ||||
|     const qrCodeElement = document.getElementById('qrCode') | ||||
|     if (qrCodeElement) { | ||||
|       QRCode.toCanvas(qrCodeElement, url.value, {scale: 8 }, error => { | ||||
|         if (error) console.error('Failed to generate QR code:', error) | ||||
|       }) | ||||
|     } | ||||
|     await drawQr() | ||||
|   } catch (error) { | ||||
|     authStore.showMessage(`Failed to create device link: ${error.message}`, 'error') | ||||
|     authStore.currentView = 'profile' | ||||
|   } | ||||
| }) | ||||
|  | ||||
| </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,8 +1,13 @@ | ||||
| <template> | ||||
|   <div class="container"> | ||||
|     <div class="view active"> | ||||
|   <h1>🔐 {{ (authStore.settings?.rp_name || 'Passkey') + ' Login' }}</h1> | ||||
|       <form @submit.prevent="handleLogin"> | ||||
|   <div class="dialog-backdrop"> | ||||
|     <div class="dialog-container"> | ||||
|       <div class="dialog-content dialog-content--narrow"> | ||||
|         <header class="view-header"> | ||||
|           <h1>🔐 {{ (authStore.settings?.rp_name || location.origin)}}</h1> | ||||
|           <p class="view-lede">User authentication is required for access.</p> | ||||
|         </header> | ||||
|         <section class="section-block"> | ||||
|           <form class="section-body" @submit.prevent="handleLogin"> | ||||
|             <button | ||||
|               type="submit" | ||||
|               class="btn-primary" | ||||
| @@ -11,6 +16,8 @@ | ||||
|               {{ authStore.isLoading ? 'Authenticating...' : 'Login with Your Device' }} | ||||
|             </button> | ||||
|           </form> | ||||
|         </section> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -22,20 +29,29 @@ 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> | ||||
|  | ||||
| <style scoped> | ||||
| .view-lede { | ||||
|   margin: 0; | ||||
|   color: var(--color-text-muted); | ||||
| } | ||||
|  | ||||
| .section-body { | ||||
|   gap: 1.5rem; | ||||
| } | ||||
|  | ||||
| @media (max-width: 720px) { | ||||
|   button { | ||||
|     width: 100%; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
							
								
								
									
										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,7 +1,13 @@ | ||||
| <template> | ||||
|   <div class="container"> | ||||
|     <div class="view active"> | ||||
|   <h1>👋 Welcome! <a v-if="isAdmin" href="/auth/admin/" class="admin-link" title="Admin Console">Admin</a></h1> | ||||
|   <section class="view-root" data-view="profile"> | ||||
|     <div class="view-content"> | ||||
|       <header class="view-header"> | ||||
|         <h1>👋 Welcome!</h1> | ||||
|   <Breadcrumbs :entries="breadcrumbEntries" /> | ||||
|         <p class="view-lede">Manage your account details and passkeys.</p> | ||||
|       </header> | ||||
|  | ||||
|       <section class="section-block"> | ||||
|         <UserBasicInfo | ||||
|           v-if="authStore.userInfo?.user" | ||||
|           :name="authStore.userInfo.user.user_name" | ||||
| @@ -11,82 +17,79 @@ | ||||
|           :loading="authStore.isLoading" | ||||
|           update-endpoint="/auth/api/user/display-name" | ||||
|           @saved="authStore.loadUserInfo()" | ||||
|           @edit-name="openNameDialog" | ||||
|         /> | ||||
|       </section> | ||||
|  | ||||
|       <section class="section-block"> | ||||
|         <div class="section-header"> | ||||
|           <h2>Your Passkeys</h2> | ||||
|       <div class="credential-list"> | ||||
|         <div v-if="authStore.isLoading"> | ||||
|           <p>Loading credentials...</p> | ||||
|           <p class="section-description">Keep at least one trusted passkey so you can always sign in.</p> | ||||
|         </div> | ||||
|         <div v-else-if="authStore.userInfo?.credentials?.length === 0"> | ||||
|           <p>No passkeys found.</p> | ||||
|         </div> | ||||
|         <div v-else> | ||||
|           <div | ||||
|             v-for="credential in authStore.userInfo?.credentials || []" | ||||
|             :key="credential.credential_uuid" | ||||
|             :class="['credential-item', { 'current-session': credential.is_current_session }]" | ||||
|           > | ||||
|             <div class="credential-header"> | ||||
|               <div class="credential-icon"> | ||||
|                 <img | ||||
|                   v-if="getCredentialAuthIcon(credential)" | ||||
|                   :src="getCredentialAuthIcon(credential)" | ||||
|                   :alt="getCredentialAuthName(credential)" | ||||
|                   class="auth-icon" | ||||
|                   width="32" | ||||
|                   height="32" | ||||
|                 > | ||||
|                 <span v-else class="auth-emoji">🔑</span> | ||||
|               </div> | ||||
|               <div class="credential-info"> | ||||
|                 <h4>{{ getCredentialAuthName(credential) }}</h4> | ||||
|               </div> | ||||
|               <div class="credential-dates"> | ||||
|                 <span class="date-label">Created:</span> | ||||
|                 <span class="date-value">{{ formatDate(credential.created_at) }}</span> | ||||
|                 <span class="date-label">Last used:</span> | ||||
|                 <span class="date-value">{{ formatDate(credential.last_used) }}</span> | ||||
|               </div> | ||||
|               <div class="credential-actions"> | ||||
|                 <button | ||||
|                   @click="deleteCredential(credential.credential_uuid)" | ||||
|                   class="btn-delete-credential" | ||||
|                   :disabled="credential.is_current_session" | ||||
|                   :title="credential.is_current_session ? 'Cannot delete current session credential' : ''" | ||||
|                 > | ||||
|                   🗑️ | ||||
|                 </button> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <div class="button-group" style="display: flex; gap: 10px;"> | ||||
|         <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="authStore.currentView = 'device-link'" class="btn-primary"> | ||||
|             <button @click="authStore.currentView = 'device-link'" class="btn-secondary"> | ||||
|               Add Another Device | ||||
|             </button> | ||||
|           </div> | ||||
|       <button @click="logout" class="btn-danger" style="width: 100%;"> | ||||
|         </div> | ||||
|       </section> | ||||
|  | ||||
|       <section class="section-block"> | ||||
|         <div class="button-row"> | ||||
|           <button @click="logout" class="btn-danger logout-button"> | ||||
|             Logout | ||||
|           </button> | ||||
|         </div> | ||||
|       </section> | ||||
|  | ||||
|       <!-- Name Edit Dialog --> | ||||
|       <Modal v-if="showNameDialog" @close="showNameDialog = false"> | ||||
|         <h3>Edit Display Name</h3> | ||||
|         <form @submit.prevent="saveName" class="modal-form"> | ||||
|           <NameEditForm | ||||
|             label="Display Name" | ||||
|             v-model="newName" | ||||
|             :busy="saving" | ||||
|             @cancel="showNameDialog = false" | ||||
|           /> | ||||
|         </form> | ||||
|       </Modal> | ||||
|     </div> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, onMounted, onUnmounted, computed } from 'vue' | ||||
| import { useAuthStore } from '@/stores/auth' | ||||
| import { formatDate } from '@/utils/helpers' | ||||
| import passkey from '@/utils/passkey' | ||||
| import { ref, onMounted, onUnmounted, computed, watch } from 'vue' | ||||
| import Breadcrumbs from '@/components/Breadcrumbs.vue' | ||||
| import CredentialList from '@/components/CredentialList.vue' | ||||
| import UserBasicInfo from '@/components/UserBasicInfo.vue' | ||||
| import Modal from '@/components/Modal.vue' | ||||
| import NameEditForm from '@/components/NameEditForm.vue' | ||||
| import { useAuthStore } from '@/stores/auth' | ||||
| import passkey from '@/utils/passkey' | ||||
|  | ||||
| const authStore = useAuthStore() | ||||
| const updateInterval = ref(null) | ||||
| const showNameDialog = ref(false) | ||||
| const newName = ref('') | ||||
| const saving = ref(false) | ||||
|  | ||||
| watch(showNameDialog, (newVal) => { | ||||
|   if (newVal) { | ||||
|     newName.value = authStore.userInfo?.user?.user_name || '' | ||||
|   } | ||||
| }) | ||||
|  | ||||
| onMounted(() => { | ||||
|   updateInterval.value = setInterval(() => { | ||||
| @@ -103,20 +106,6 @@ onUnmounted(() => { | ||||
|   } | ||||
| }) | ||||
|  | ||||
| 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 () => { | ||||
|   try { | ||||
|     authStore.isLoading = true | ||||
| @@ -132,7 +121,9 @@ const addNewCredential = async () => { | ||||
|   } | ||||
| } | ||||
|  | ||||
| const deleteCredential = async (credentialId) => { | ||||
| const handleDelete = async (credential) => { | ||||
|   const credentialId = credential?.credential_uuid | ||||
|   if (!credentialId) return | ||||
|   if (!confirm('Are you sure you want to delete this passkey?')) return | ||||
|   try { | ||||
|     await authStore.deleteCredential(credentialId) | ||||
| @@ -146,23 +137,71 @@ 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 breadcrumbEntries = computed(() => { | ||||
|   const entries = [{ label: 'Auth', href: authStore.uiHref() }] | ||||
|   if (isAdmin.value) entries.push({ label: 'Admin', href: authStore.adminHomeHref() }) | ||||
|   return entries | ||||
| }) | ||||
|  | ||||
| const saveName = async () => { | ||||
|   const name = newName.value.trim() | ||||
|   if (!name) { | ||||
|     authStore.showMessage('Name cannot be empty', 'error') | ||||
|     return | ||||
|   } | ||||
|   try { | ||||
|     saving.value = true | ||||
|     const res = await fetch('/auth/api/user/display-name', { | ||||
|       method: 'PUT', | ||||
|       headers: { 'content-type': 'application/json' }, | ||||
|       body: JSON.stringify({ display_name: name }) | ||||
|     }) | ||||
|     const data = await res.json() | ||||
|     if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed') | ||||
|   showNameDialog.value = false | ||||
|     await authStore.loadUserInfo() | ||||
|     authStore.showMessage('Name updated successfully!', 'success', 3000) | ||||
|   } catch (e) { | ||||
|     authStore.showMessage(e.message || 'Failed to update name', 'error') | ||||
|   } finally { | ||||
|     saving.value = false | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| /* Removed inline user info styles; now provided by UserBasicInfo component */ | ||||
| .admin-link { | ||||
|   font-size: 0.6em; | ||||
|   margin-left: 0.75rem; | ||||
|   text-decoration: none; | ||||
|   background: var(--color-background-soft, #eee); | ||||
|   padding: 0.2em 0.6em; | ||||
|   border-radius: 999px; | ||||
|   border: 1px solid var(--color-border, #ccc); | ||||
|   vertical-align: middle; | ||||
|   line-height: 1.2; | ||||
| .view-lede { | ||||
|   margin: 0; | ||||
|   color: var(--color-text-muted); | ||||
|   font-size: 1rem; | ||||
| } | ||||
| .admin-link:hover { | ||||
|   background: var(--color-background-mute, #ddd); | ||||
|  | ||||
| .section-header { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 0.4rem; | ||||
| } | ||||
|  | ||||
| .section-description { | ||||
|   margin: 0; | ||||
|   color: var(--color-text-muted); | ||||
| } | ||||
|  | ||||
| .logout-button { | ||||
|   align-self: flex-start; | ||||
| } | ||||
|  | ||||
| @media (max-width: 720px) { | ||||
|   .logout-button { | ||||
|     width: 100%; | ||||
|   } | ||||
| } | ||||
| </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> | ||||
| @@ -2,21 +2,9 @@ | ||||
|   <div v-if="userLoaded" class="user-info"> | ||||
|     <h3 class="user-name-heading"> | ||||
|       <span class="icon">👤</span> | ||||
|       <span v-if="!editingName" class="user-name-row"> | ||||
|       <span class="user-name-row"> | ||||
|         <span class="display-name" :title="name">{{ name }}</span> | ||||
|         <button v-if="canEdit && updateEndpoint" class="mini-btn" @click="startEdit" title="Edit name">✏️</button> | ||||
|       </span> | ||||
|       <span v-else class="user-name-row editing"> | ||||
|         <input | ||||
|           v-model="newName" | ||||
|           class="name-input" | ||||
|           :placeholder="name" | ||||
|           :disabled="busy || loading" | ||||
|           maxlength="64" | ||||
|           @keyup.enter="saveName" | ||||
|         /> | ||||
|         <button class="mini-btn" @click="saveName" :disabled="busy || loading" title="Save name">💾</button> | ||||
|         <button class="mini-btn" @click="cancelEdit" :disabled="busy || loading" title="Cancel">✖</button> | ||||
|         <button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('editName')" title="Edit name">✏️</button> | ||||
|       </span> | ||||
|     </h3> | ||||
|     <div v-if="orgDisplayName || roleName" class="org-role-sub"> | ||||
| @@ -49,53 +37,29 @@ const props = defineProps({ | ||||
|   roleName: { type: String, default: '' } | ||||
| }) | ||||
|  | ||||
| const emit = defineEmits(['saved']) | ||||
| const emit = defineEmits(['saved', 'editName']) | ||||
| const authStore = useAuthStore() | ||||
|  | ||||
| const editingName = ref(false) | ||||
| const newName = ref('') | ||||
| const busy = ref(false) | ||||
| const userLoaded = computed(() => !!props.name) | ||||
|  | ||||
| function startEdit() { editingName.value = true; newName.value = '' } | ||||
| function cancelEdit() { editingName.value = false } | ||||
| async function saveName() { | ||||
|   if (!props.updateEndpoint) { editingName.value = false; return } | ||||
|   try { | ||||
|     busy.value = true | ||||
|     authStore.isLoading = true | ||||
|     const bodyName = newName.value.trim() | ||||
|     if (!bodyName) { cancelEdit(); return } | ||||
|     const res = await fetch(props.updateEndpoint, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: bodyName }) }) | ||||
|     let data = {} | ||||
|     try { data = await res.json() } catch (_) {} | ||||
|     if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed') | ||||
|     editingName.value = false | ||||
|     authStore.showMessage('Name updated', 'success', 1500) | ||||
|     emit('saved') | ||||
|   } catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') } | ||||
|   finally { busy.value = false; authStore.isLoading = false } | ||||
| } | ||||
| watch(() => props.name, () => { if (!props.name) editingName.value = false }) | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .user-info { display: grid; grid-template-columns: auto 1fr; gap: 10px; } | ||||
| .user-info h3 { grid-column: span 2; } | ||||
| .org-role-sub { grid-column: span 2; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; } | ||||
| .org-line { font-size: .7rem; font-weight:600; line-height:1.1; } | ||||
| .role-line { font-size:.6rem; color:#555; line-height:1.1; } | ||||
| .org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; } | ||||
| .role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; } | ||||
| .user-info span { text-align: left; } | ||||
| .user-name-heading { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0 0 0.25rem 0; } | ||||
| .user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; } | ||||
| .user-name-row.editing { flex: 1 1 auto; } | ||||
| .icon { flex: 0 0 auto; } | ||||
| .display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | ||||
| .name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid #a9c5d6; border-radius: 6px; } | ||||
| .name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid var(--color-border-strong); border-radius: 6px; background: var(--color-surface); color: var(--color-text); } | ||||
| .user-name-heading .name-input { width: auto; } | ||||
| .name-input:focus { outline: 2px solid #667eea55; border-color: #667eea; } | ||||
| .mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; background: #eef5fa; border: 1px solid #b7d2e3; border-radius: 6px; cursor: pointer; transition: background 0.2s, transform 0.15s; } | ||||
| .mini-btn:hover:not(:disabled) { background: #dcecf6; } | ||||
| .name-input:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); } | ||||
| .mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; background: var(--color-surface-muted); border: 1px solid var(--color-border-strong); border-radius: 6px; cursor: pointer; transition: background 0.2s, transform 0.15s, color 0.2s ease; color: var(--color-text); } | ||||
| .mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); } | ||||
| .mini-btn:active:not(:disabled) { transform: translateY(1px); } | ||||
| .mini-btn:disabled { opacity: 0.5; cursor: not-allowed; } | ||||
| @media (max-width: 480px) { .user-name-heading { flex-direction: column; align-items: flex-start; } .user-name-row.editing { width: 100%; } .display-name { max-width: 100%; } } | ||||
|   | ||||
							
								
								
									
										258
									
								
								frontend/src/reset/ResetApp.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										258
									
								
								frontend/src/reset/ResetApp.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,258 @@ | ||||
| <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>🔑 Complete Your Passkey Setup</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" | ||||
|                   :placeholder="namePlaceholder" | ||||
|                   :disabled="loading" | ||||
|                   maxlength="64" | ||||
|                   @keyup.enter="registerPasskey" | ||||
|                 /> | ||||
|               </label> | ||||
|               <p>Click below to finish {{ sessionDescriptor }}.</p> | ||||
|               <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' | ||||
|  | ||||
| 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 namePlaceholder = computed(() => userInfo.value?.user?.user_name || 'Your name') | ||||
| const subtitleMessage = computed(() => { | ||||
|   if (initializing.value) return 'Preparing your secure enrollment…' | ||||
|   if (!canRegister.value) return 'This reset link is no longer valid.' | ||||
|   return `Finish setting up a passkey for ${userInfo.value?.user?.user_name || 'your account'}.` | ||||
| }) | ||||
|  | ||||
| const uiBasePath = computed(() => { | ||||
|   const base = settings.value?.ui_base_path || '/auth/' | ||||
|   if (base === '/') return '/' | ||||
|   return base.endsWith('/') ? base : `${base}/` | ||||
| }) | ||||
|  | ||||
| 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 res = await fetch('/auth/api/settings') | ||||
|     if (!res.ok) return | ||||
|     const data = await res.json() | ||||
|     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() | ||||
|   } 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') | ||||
							
								
								
									
										207
									
								
								frontend/src/restricted/RestrictedApp.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										207
									
								
								frontend/src/restricted/RestrictedApp.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,207 @@ | ||||
| <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: 520px; margin: 0 auto; width: 100%;"> | ||||
|           <header class="view-header" style="text-align: center;"> | ||||
|             <h1>🚫 Access Restricted</h1> | ||||
|             <p class="view-lede">{{ headerMessage }}</p> | ||||
|           </header> | ||||
|  | ||||
|           <section class="section-block" v-if="initializing"> | ||||
|             <div class="section-body center"> | ||||
|               <p>Checking your session…</p> | ||||
|             </div> | ||||
|           </section> | ||||
|  | ||||
|           <section class="section-block" v-else> | ||||
|             <div class="section-body center" style="gap: 1.75rem;"> | ||||
|               <p>{{ detailText }}</p> | ||||
|  | ||||
|               <div class="button-row center" style="justify-content: center;"> | ||||
|                 <button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser"> | ||||
|                   {{ loading ? 'Signing in…' : 'Sign in with Passkey' }} | ||||
|                 </button> | ||||
|                 <button class="btn-secondary" :disabled="loading" @click="returnHome"> | ||||
|                   Go back to Auth Home | ||||
|                 </button> | ||||
|               </div> | ||||
|             </div> | ||||
|           </section> | ||||
|         </div> | ||||
|       </div> | ||||
|     </main> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { computed, onMounted, reactive, ref } from 'vue' | ||||
| import passkey from '@/utils/passkey' | ||||
|  | ||||
| const status = reactive({ | ||||
|   show: false, | ||||
|   message: '', | ||||
|   type: 'info' | ||||
| }) | ||||
|  | ||||
| const initializing = ref(true) | ||||
| const loading = ref(false) | ||||
| const settings = ref(null) | ||||
| const userInfo = ref(null) | ||||
| const fallbackDetail = ref('') | ||||
| let statusTimer = null | ||||
|  | ||||
| const isAuthenticated = computed(() => !!userInfo.value?.authenticated) | ||||
| const canAuthenticate = computed(() => !initializing.value && !isAuthenticated.value) | ||||
| const uiBasePath = computed(() => { | ||||
|   const base = settings.value?.ui_base_path || '/auth/' | ||||
|   if (base === '/') return '/' | ||||
|   return base.endsWith('/') ? base : `${base}/` | ||||
| }) | ||||
|  | ||||
| const headerMessage = computed(() => { | ||||
|   if (initializing.value) return 'Checking your access permissions…' | ||||
|   if (isAuthenticated.value) { | ||||
|     return 'Your account is signed in, but this resource needs extra permissions.' | ||||
|   } | ||||
|   return 'Sign in to continue to the requested resource.' | ||||
| }) | ||||
|  | ||||
| const detailText = computed(() => { | ||||
|   if (isAuthenticated.value) { | ||||
|     return fallbackDetail.value || 'You do not have the required permissions to view this page.' | ||||
|   } | ||||
|   return fallbackDetail.value || 'Use your registered passkey to sign in securely.' | ||||
| }) | ||||
|  | ||||
| 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 res = await fetch('/auth/api/settings') | ||||
|     if (!res.ok) return | ||||
|     const data = await res.json() | ||||
|     settings.value = data | ||||
|     if (data?.rp_name) { | ||||
|       document.title = `${data.rp_name} · Access Restricted` | ||||
|     } | ||||
|   } catch (error) { | ||||
|     console.warn('Unable to load settings', error) | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function fetchUserInfo() { | ||||
|   try { | ||||
|     const res = await fetch('/auth/api/user-info', { method: 'POST' }) | ||||
|     if (!res.ok) { | ||||
|       const payload = await safeParseJson(res) | ||||
|       fallbackDetail.value = payload?.detail || 'Please sign in to continue.' | ||||
|       return | ||||
|     } | ||||
|     userInfo.value = await res.json() | ||||
|   } catch (error) { | ||||
|     console.error('Failed to load user info', error) | ||||
|     fallbackDetail.value = 'We were unable to verify your session. Try again shortly.' | ||||
|   } | ||||
| } | ||||
|  | ||||
| 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 | ||||
|   } | ||||
|  | ||||
|   showMessage('Signed in successfully!', 'success', 2000) | ||||
|   setTimeout(() => { | ||||
|     loading.value = false | ||||
|     window.location.reload() | ||||
|   }, 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 returnHome() { | ||||
|   const target = uiBasePath.value || '/auth/' | ||||
|   if (window.location.pathname !== target) { | ||||
|     history.replaceState(null, '', target) | ||||
|   } | ||||
|   window.location.href = target | ||||
| } | ||||
|  | ||||
| async function safeParseJson(response) { | ||||
|   try { | ||||
|     return await response.json() | ||||
|   } catch (error) { | ||||
|     return null | ||||
|   } | ||||
| } | ||||
|  | ||||
| onMounted(async () => { | ||||
|   await fetchSettings() | ||||
|   await fetchUserInfo() | ||||
|   if (!canAuthenticate.value && !isAuthenticated.value && !fallbackDetail.value) { | ||||
|     fallbackDetail.value = 'Please try signing in again.' | ||||
|   } | ||||
|   initializing.value = false | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .center { | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| .button-row.center { | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
|   gap: 0.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') | ||||
| @@ -7,8 +7,6 @@ export const useAuthStore = defineStore('auth', { | ||||
|     userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated} | ||||
|     settings: null, // Server provided settings (/auth/settings) | ||||
|     isLoading: false, | ||||
|     resetToken: null, // transient reset token | ||||
|     restrictedMode: false, // Anywhere other than /auth/: restrict to login or permission denied | ||||
|  | ||||
|     // UI State | ||||
|     currentView: 'login', | ||||
| @@ -18,7 +16,21 @@ export const useAuthStore = defineStore('auth', { | ||||
|       show: false | ||||
|     }, | ||||
|   }), | ||||
|   getters: { | ||||
|     uiBasePath(state) { | ||||
|       const configured = state.settings?.ui_base_path || '/auth/' | ||||
|       if (!configured.endsWith('/')) return `${configured}/` | ||||
|       return configured | ||||
|     }, | ||||
|     adminUiPath() { | ||||
|       const base = this.uiBasePath | ||||
|       return base === '/' ? '/admin/' : `${base}admin/` | ||||
|     }, | ||||
|   }, | ||||
|   actions: { | ||||
|     setLoading(flag) { | ||||
|       this.isLoading = !!flag | ||||
|     }, | ||||
|     showMessage(message, type = 'info', duration = 3000) { | ||||
|       this.status = { | ||||
|         message, | ||||
| @@ -31,6 +43,15 @@ export const useAuthStore = defineStore('auth', { | ||||
|         }, duration) | ||||
|       } | ||||
|     }, | ||||
|     uiHref(suffix = '') { | ||||
|       const trimmed = suffix.startsWith('/') ? suffix.slice(1) : suffix | ||||
|       if (!trimmed) return this.uiBasePath | ||||
|       if (this.uiBasePath === '/') return `/${trimmed}` | ||||
|       return `${this.uiBasePath}${trimmed}` | ||||
|     }, | ||||
|     adminHomeHref() { | ||||
|       return this.adminUiPath | ||||
|     }, | ||||
|     async setSessionCookie(sessionToken) { | ||||
|       const response = await fetch('/auth/api/set-session', { | ||||
|         method: 'POST', | ||||
| @@ -40,9 +61,6 @@ export const useAuthStore = defineStore('auth', { | ||||
|       if (result.detail) { | ||||
|         throw new Error(result.detail) | ||||
|       } | ||||
|   // On successful session establishment, discard any reset token to avoid | ||||
|   // sending stale Authorization headers on subsequent API calls. | ||||
|   this.resetToken = null | ||||
|       return result | ||||
|     }, | ||||
|     async register() { | ||||
| @@ -51,6 +69,7 @@ export const useAuthStore = defineStore('auth', { | ||||
|         const result = await register() | ||||
|         await this.setSessionCookie(result.session_token) | ||||
|         await this.loadUserInfo() | ||||
|         this.selectView() | ||||
|         return result | ||||
|       } finally { | ||||
|         this.isLoading = false | ||||
| @@ -63,6 +82,7 @@ export const useAuthStore = defineStore('auth', { | ||||
|  | ||||
|         await this.setSessionCookie(result.session_token) | ||||
|         await this.loadUserInfo() | ||||
|         this.selectView() | ||||
|  | ||||
|         return result | ||||
|       } finally { | ||||
| @@ -70,25 +90,12 @@ export const useAuthStore = defineStore('auth', { | ||||
|       } | ||||
|     }, | ||||
|     selectView() { | ||||
|       if (this.restrictedMode) { | ||||
|         // In restricted mode only allow login or show permission denied if already authenticated | ||||
|         if (!this.userInfo) this.currentView = 'login' | ||||
|         else if (this.userInfo.authenticated) this.currentView = 'permission-denied' | ||||
|         else this.currentView = 'login' // do not expose reset/registration flows outside /auth/ | ||||
|         return | ||||
|       } | ||||
|       if (!this.userInfo) this.currentView = 'login' | ||||
|       else if (this.userInfo.authenticated) this.currentView = 'profile' | ||||
|       else this.currentView = 'reset' | ||||
|     }, | ||||
|     setRestrictedMode(flag) { | ||||
|       this.restrictedMode = !!flag | ||||
|       else this.currentView = 'login' | ||||
|     }, | ||||
|     async loadUserInfo() { | ||||
|       const headers = {} | ||||
|       // Reset tokens are only passed via query param now, not Authorization header | ||||
|   const url = this.resetToken ? `/auth/api/user-info?reset=${encodeURIComponent(this.resetToken)}` : '/auth/api/user-info' | ||||
|       const response = await fetch(url, { method: 'POST', headers }) | ||||
|       const response = await fetch('/auth/api/user-info', { method: 'POST' }) | ||||
|       let result = null | ||||
|       try { | ||||
|         result = await response.json() | ||||
| @@ -130,6 +137,7 @@ export const useAuthStore = defineStore('auth', { | ||||
|     async logout() { | ||||
|       try { | ||||
|         await fetch('/auth/api/logout', {method: 'POST'}) | ||||
|         sessionStorage.clear() | ||||
|         location.reload() | ||||
|       } catch (error) { | ||||
|         console.error('Logout error:', error) | ||||
|   | ||||
| @@ -35,6 +35,10 @@ export default defineConfig(({ command, mode }) => ({ | ||||
|           if (url === '/auth/' || url === '/auth') return '/' | ||||
|           if (url === '/auth/admin' || url === '/auth/admin/') return '/admin/' | ||||
|           if (url.startsWith('/auth/assets/')) return url.replace(/^\/auth/, '') | ||||
|           if (/^\/auth\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html' | ||||
|           if (/^\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html' | ||||
|           if (url === '/auth/restricted' || url === '/auth/restricted/') return '/restricted/index.html' | ||||
|           if (url === '/restricted' || url === '/restricted/') return '/restricted/index.html' | ||||
|           // Everything else (including /auth/admin/* APIs) should proxy. | ||||
|         } | ||||
|       } | ||||
| @@ -47,7 +51,9 @@ export default defineConfig(({ command, mode }) => ({ | ||||
|     rollupOptions: { | ||||
|       input: { | ||||
|         index: resolve(__dirname, 'index.html'), | ||||
|         admin: resolve(__dirname, 'admin/index.html') | ||||
|         admin: resolve(__dirname, 'admin/index.html'), | ||||
|         reset: resolve(__dirname, 'reset/index.html'), | ||||
|         restricted: resolve(__dirname, 'restricted/index.html') | ||||
|       }, | ||||
|       output: {} | ||||
|     } | ||||
|   | ||||
| @@ -14,7 +14,7 @@ import uuid7 | ||||
|  | ||||
| from . import authsession, globals | ||||
| from .db import Org, Permission, Role, User | ||||
| from .util import passphrase, tokens | ||||
| from .util import hostutil, passphrase, tokens | ||||
|  | ||||
|  | ||||
| def _init_logger() -> logging.Logger: | ||||
| @@ -47,7 +47,7 @@ async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> | ||||
|         expires=authsession.expires(), | ||||
|         info={"type": session_type}, | ||||
|     ) | ||||
|     reset_link = f"{globals.passkey.instance.origin}/auth/{token}" | ||||
|     reset_link = hostutil.reset_link_url(token) | ||||
|     logger.info(ADMIN_RESET_MESSAGE, message, reset_link) | ||||
|     return reset_link | ||||
|  | ||||
|   | ||||
| @@ -17,6 +17,7 @@ from sqlalchemy import ( | ||||
|     String, | ||||
|     delete, | ||||
|     event, | ||||
|     insert, | ||||
|     select, | ||||
|     update, | ||||
| ) | ||||
| @@ -971,8 +972,10 @@ class DB(DatabaseInterface): | ||||
|             ) | ||||
|             if role.permissions: | ||||
|                 for perm_id in set(role.permissions): | ||||
|                     session.add( | ||||
|                         RolePermission(role_uuid=role.uuid.bytes, permission_id=perm_id) | ||||
|                     await session.execute( | ||||
|                         insert(RolePermission).values( | ||||
|                             role_uuid=role.uuid.bytes, permission_id=perm_id | ||||
|                         ) | ||||
|                     ) | ||||
|  | ||||
|     async def delete_role(self, role_uuid: UUID) -> None: | ||||
| @@ -1200,10 +1203,15 @@ class DB(DatabaseInterface): | ||||
|             org_perm_result = await session.execute(org_perm_stmt) | ||||
|             organization.permissions = [row[0] for row in org_perm_result.fetchall()] | ||||
|  | ||||
|             # Filter effective permissions: only include permissions that the org can grant | ||||
|             effective_permissions = [ | ||||
|                 p for p in permissions if p.id in organization.permissions | ||||
|             ] | ||||
|  | ||||
|             return SessionContext( | ||||
|                 session=session_obj, | ||||
|                 user=user_obj, | ||||
|                 org=organization, | ||||
|                 role=role, | ||||
|                 permissions=permissions if permissions else None, | ||||
|                 permissions=effective_permissions if effective_permissions else None, | ||||
|             ) | ||||
|   | ||||
| @@ -94,6 +94,13 @@ def add_common_options(p: argparse.ArgumentParser) -> None: | ||||
|     ) | ||||
|     p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)") | ||||
|     p.add_argument("--origin", help="Origin URL (default: https://<rp-id>)") | ||||
|     p.add_argument( | ||||
|         "--auth-host", | ||||
|         help=( | ||||
|             "Dedicated host (optionally with scheme/port) to serve the auth UI at the root," | ||||
|             " e.g. auth.example.com or https://auth.example.com" | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def main(): | ||||
| @@ -168,6 +175,16 @@ def main(): | ||||
|         os.environ["PASSKEY_RP_NAME"] = args.rp_name | ||||
|     if origin: | ||||
|         os.environ["PASSKEY_ORIGIN"] = origin | ||||
|     if getattr(args, "auth_host", None): | ||||
|         os.environ["PASSKEY_AUTH_HOST"] = args.auth_host | ||||
|     else: | ||||
|         # Preserve pre-set env variable if CLI option omitted | ||||
|         args.auth_host = os.environ.get("PASSKEY_AUTH_HOST") | ||||
|  | ||||
|     if getattr(args, "auth_host", None): | ||||
|         from passkey.util import hostutil as _hostutil  # local import | ||||
|  | ||||
|         _hostutil.reload_config() | ||||
|  | ||||
|     # One-time initialization + bootstrap before starting any server processes. | ||||
|     # Lifespan in worker processes will call globals.init with bootstrap disabled. | ||||
|   | ||||
| @@ -1,13 +1,12 @@ | ||||
| import logging | ||||
| from uuid import UUID, uuid4 | ||||
|  | ||||
| from fastapi import Body, Cookie, FastAPI, HTTPException | ||||
| from fastapi import Body, Cookie, FastAPI, HTTPException, Request | ||||
| from fastapi.responses import FileResponse, JSONResponse | ||||
|  | ||||
| from ..authsession import expires | ||||
| from ..globals import db | ||||
| from ..globals import passkey as global_passkey | ||||
| from ..util import frontend, passphrase, permutil, querysafe, tokens | ||||
| from ..util import frontend, hostutil, passphrase, permutil, querysafe, tokens | ||||
| from . import authz | ||||
|  | ||||
| app = FastAPI() | ||||
| @@ -77,12 +76,24 @@ async def admin_list_orgs(auth=Cookie(None)): | ||||
| async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)): | ||||
|     await authz.verify(auth, ["auth:admin"]) | ||||
|     from ..db import Org as OrgDC  # local import to avoid cycles | ||||
|     from ..db import Role as RoleDC  # local import to avoid cycles | ||||
|  | ||||
|     org_uuid = uuid4() | ||||
|     display_name = payload.get("display_name") or "New Organization" | ||||
|     permissions = payload.get("permissions") or [] | ||||
|     org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) | ||||
|     await db.instance.create_organization(org) | ||||
|  | ||||
|     # Automatically create Administration role with org admin permission | ||||
|     role_uuid = uuid4() | ||||
|     admin_role = RoleDC( | ||||
|         uuid=role_uuid, | ||||
|         org_uuid=org_uuid, | ||||
|         display_name="Administration", | ||||
|         permissions=[f"auth:org:{org_uuid}"], | ||||
|     ) | ||||
|     await db.instance.create_role(admin_role) | ||||
|  | ||||
|     return {"uuid": str(org_uuid)} | ||||
|  | ||||
|  | ||||
| @@ -90,7 +101,7 @@ async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)): | ||||
| async def admin_update_org( | ||||
|     org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) | ||||
| ): | ||||
|     await authz.verify( | ||||
|     ctx = await authz.verify( | ||||
|         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any | ||||
|     ) | ||||
|     from ..db import Org as OrgDC  # local import to avoid cycles | ||||
| @@ -98,6 +109,20 @@ async def admin_update_org( | ||||
|     current = await db.instance.get_organization(str(org_uuid)) | ||||
|     display_name = payload.get("display_name") or current.display_name | ||||
|     permissions = payload.get("permissions") or current.permissions or [] | ||||
|  | ||||
|     # Sanity check: prevent removing permissions that would break current user's admin access | ||||
|     org_admin_perm = f"auth:org:{org_uuid}" | ||||
|  | ||||
|     # If current user is org admin (not global admin), ensure org admin perm remains | ||||
|     if ( | ||||
|         "auth:admin" not in ctx.role.permissions | ||||
|         and f"auth:org:{org_uuid}" in ctx.role.permissions | ||||
|     ): | ||||
|         if org_admin_perm not in permissions: | ||||
|             raise ValueError( | ||||
|                 "Cannot remove organization admin permission from your own organization" | ||||
|             ) | ||||
|  | ||||
|     org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) | ||||
|     await db.instance.update_organization(org) | ||||
|     return {"status": "ok"} | ||||
| @@ -110,6 +135,21 @@ async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)): | ||||
|     ) | ||||
|     if ctx.org.uuid == org_uuid: | ||||
|         raise ValueError("Cannot delete the organization you belong to") | ||||
|  | ||||
|     # Delete organization-specific permissions | ||||
|     org_perm_pattern = f"org:{str(org_uuid).lower()}" | ||||
|     all_permissions = await db.instance.list_permissions() | ||||
|     for perm in all_permissions: | ||||
|         perm_id_lower = perm.id.lower() | ||||
|         # Check if permission contains "org:{uuid}" separated by colons or at boundaries | ||||
|         if ( | ||||
|             f":{org_perm_pattern}:" in perm_id_lower | ||||
|             or perm_id_lower.startswith(f"{org_perm_pattern}:") | ||||
|             or perm_id_lower.endswith(f":{org_perm_pattern}") | ||||
|             or perm_id_lower == org_perm_pattern | ||||
|         ): | ||||
|             await db.instance.delete_permission(perm.id) | ||||
|  | ||||
|     await db.instance.delete_organization(org_uuid) | ||||
|     return {"status": "ok"} | ||||
|  | ||||
| @@ -139,7 +179,9 @@ async def admin_remove_org_permission( | ||||
| async def admin_create_role( | ||||
|     org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) | ||||
| ): | ||||
|     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 | ||||
|     ) | ||||
|     from ..db import Role as RoleDC | ||||
|  | ||||
|     role_uuid = uuid4() | ||||
| @@ -166,7 +208,7 @@ async def admin_update_role( | ||||
|     org_uuid: UUID, role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) | ||||
| ): | ||||
|     # 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 | ||||
|     ) | ||||
|     role = await db.instance.get_role(role_uuid) | ||||
| @@ -175,13 +217,25 @@ async def admin_update_role( | ||||
|     from ..db import Role as RoleDC | ||||
|  | ||||
|     display_name = payload.get("display_name") or role.display_name | ||||
|     permissions = payload.get("permissions") or role.permissions | ||||
|     permissions = payload.get("permissions") | ||||
|     if permissions is None: | ||||
|         permissions = role.permissions | ||||
|     org = await db.instance.get_organization(str(org_uuid)) | ||||
|     grantable = set(org.permissions or []) | ||||
|     existing_permissions = set(role.permissions) | ||||
|     for pid in permissions: | ||||
|         await db.instance.get_permission(pid) | ||||
|         if pid not in grantable: | ||||
|         if pid not in existing_permissions and pid not in grantable: | ||||
|             raise ValueError(f"Permission not grantable by org: {pid}") | ||||
|  | ||||
|     # Sanity check: prevent admin from removing their own access via role update | ||||
|     if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid: | ||||
|         has_admin_access = ( | ||||
|             "auth:admin" in permissions or f"auth:org:{org_uuid}" in permissions | ||||
|         ) | ||||
|         if not has_admin_access: | ||||
|             raise ValueError("Cannot update your own role to remove admin permissions") | ||||
|  | ||||
|     updated = RoleDC( | ||||
|         uuid=role_uuid, | ||||
|         org_uuid=org_uuid, | ||||
| @@ -194,12 +248,17 @@ async def admin_update_role( | ||||
|  | ||||
| @app.delete("/orgs/{org_uuid}/roles/{role_uuid}") | ||||
| async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)): | ||||
|     await authz.verify( | ||||
|     ctx = await authz.verify( | ||||
|         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any | ||||
|     ) | ||||
|     role = await db.instance.get_role(role_uuid) | ||||
|     if role.org_uuid != org_uuid: | ||||
|         raise HTTPException(status_code=404, detail="Role not found in organization") | ||||
|  | ||||
|     # Sanity check: prevent admin from deleting their own role | ||||
|     if ctx.role.uuid == role_uuid: | ||||
|         raise ValueError("Cannot delete your own role") | ||||
|  | ||||
|     await db.instance.delete_role(role_uuid) | ||||
|     return {"status": "ok"} | ||||
|  | ||||
| @@ -240,7 +299,7 @@ async def admin_create_user( | ||||
| async def admin_update_user_role( | ||||
|     org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) | ||||
| ): | ||||
|     await authz.verify( | ||||
|     ctx = await authz.verify( | ||||
|         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any | ||||
|     ) | ||||
|     new_role = payload.get("role") | ||||
| @@ -255,13 +314,27 @@ async def admin_update_user_role( | ||||
|     roles = await db.instance.get_roles_by_organization(str(org_uuid)) | ||||
|     if not any(r.display_name == new_role for r in roles): | ||||
|         raise ValueError("Role not found in organization") | ||||
|  | ||||
|     # Sanity check: prevent admin from removing their own access | ||||
|     if ctx.user.uuid == user_uuid: | ||||
|         new_role_obj = next((r for r in roles if r.display_name == new_role), None) | ||||
|         if new_role_obj: | ||||
|             has_admin_access = ( | ||||
|                 "auth:admin" in new_role_obj.permissions | ||||
|                 or f"auth:org:{org_uuid}" in new_role_obj.permissions | ||||
|             ) | ||||
|             if not has_admin_access: | ||||
|                 raise ValueError( | ||||
|                     "Cannot change your own role to one without admin permissions" | ||||
|                 ) | ||||
|  | ||||
|     await db.instance.update_user_role_in_organization(user_uuid, new_role) | ||||
|     return {"status": "ok"} | ||||
|  | ||||
|  | ||||
| @app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link") | ||||
| async def admin_create_user_registration_link( | ||||
|     org_uuid: UUID, user_uuid: UUID, auth=Cookie(None) | ||||
|     org_uuid: UUID, user_uuid: UUID, request: Request, auth=Cookie(None) | ||||
| ): | ||||
|     try: | ||||
|         user_org, _role_name = await db.instance.get_user_organization(user_uuid) | ||||
| @@ -284,8 +357,9 @@ async def admin_create_user_registration_link( | ||||
|         expires=expires(), | ||||
|         info={"type": "device addition", "created_by_admin": True}, | ||||
|     ) | ||||
|     origin = global_passkey.instance.origin | ||||
|     url = f"{origin}/auth/{token}" | ||||
|     url = hostutil.reset_link_url( | ||||
|         token, request.url.scheme, request.headers.get("host") | ||||
|     ) | ||||
|     return {"url": url, "expires": expires().isoformat()} | ||||
|  | ||||
|  | ||||
| @@ -370,15 +444,45 @@ async def admin_update_user_display_name( | ||||
|     return {"status": "ok"} | ||||
|  | ||||
|  | ||||
| @app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}") | ||||
| async def admin_delete_user_credential( | ||||
|     org_uuid: UUID, user_uuid: UUID, credential_uuid: UUID, auth=Cookie(None) | ||||
| ): | ||||
|     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 | ||||
|     ) | ||||
|     if ( | ||||
|         "auth:admin" not in ctx.role.permissions | ||||
|         and f"auth:org:{org_uuid}" not in ctx.role.permissions | ||||
|     ): | ||||
|         raise HTTPException(status_code=403, detail="Insufficient permissions") | ||||
|     await db.instance.delete_credential(credential_uuid, user_uuid) | ||||
|     return {"status": "ok"} | ||||
|  | ||||
|  | ||||
| # -------------------- Permissions (global) -------------------- | ||||
|  | ||||
|  | ||||
| @app.get("/permissions") | ||||
| async def admin_list_permissions(auth=Cookie(None)): | ||||
|     await authz.verify(auth, ["auth:admin"], match=permutil.has_any) | ||||
|     ctx = await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any) | ||||
|     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] | ||||
|  | ||||
|     # Org admins only see permissions their org can grant | ||||
|     grantable = set(ctx.org.permissions or []) | ||||
|     filtered_perms = [p for p in perms if p.id in grantable] | ||||
|     return [{"id": p.id, "display_name": p.display_name} for p in filtered_perms] | ||||
|  | ||||
|  | ||||
| @app.post("/permissions") | ||||
| async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)): | ||||
| @@ -418,6 +522,11 @@ async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)): | ||||
|     display_name = payload.get("display_name") | ||||
|     if not old_id or not new_id: | ||||
|         raise ValueError("old_id and new_id required") | ||||
|  | ||||
|     # Sanity check: prevent renaming critical permissions | ||||
|     if old_id == "auth:admin": | ||||
|         raise ValueError("Cannot rename the master admin permission") | ||||
|  | ||||
|     querysafe.assert_safe(old_id, field="old_id") | ||||
|     querysafe.assert_safe(new_id, field="new_id") | ||||
|     if display_name is None: | ||||
| @@ -434,5 +543,10 @@ async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)): | ||||
| async def admin_delete_permission(permission_id: str, auth=Cookie(None)): | ||||
|     await authz.verify(auth, ["auth:admin"]) | ||||
|     querysafe.assert_safe(permission_id, field="permission_id") | ||||
|  | ||||
|     # Sanity check: prevent deleting critical permissions | ||||
|     if permission_id == "auth:admin": | ||||
|         raise ValueError("Cannot delete the master admin permission") | ||||
|  | ||||
|     await db.instance.delete_permission(permission_id) | ||||
|     return {"status": "ok"} | ||||
|   | ||||
| @@ -13,7 +13,7 @@ from fastapi import ( | ||||
|     Request, | ||||
|     Response, | ||||
| ) | ||||
| from fastapi.responses import FileResponse, JSONResponse | ||||
| from fastapi.responses import JSONResponse | ||||
| from fastapi.security import HTTPBearer | ||||
|  | ||||
| from passkey.util import frontend | ||||
| @@ -29,7 +29,7 @@ from ..authsession import ( | ||||
| ) | ||||
| from ..globals import db | ||||
| from ..globals import passkey as global_passkey | ||||
| from ..util import passphrase, permutil, tokens | ||||
| from ..util import hostutil, passphrase, permutil, tokens | ||||
| from ..util.tokens import session_key | ||||
| from . import authz, session | ||||
|  | ||||
| @@ -112,13 +112,20 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None)) | ||||
|         } | ||||
|         return Response(status_code=204, headers=remote_headers) | ||||
|     except HTTPException as e: | ||||
|         return FileResponse(frontend.file("index.html"), status_code=e.status_code) | ||||
|         html = frontend.file("restricted", "index.html").read_bytes() | ||||
|         return Response(html, status_code=e.status_code, media_type="text/html") | ||||
|  | ||||
|  | ||||
| @app.get("/settings") | ||||
| async def get_settings(): | ||||
|     pk = global_passkey.instance | ||||
|     return {"rp_id": pk.rp_id, "rp_name": pk.rp_name} | ||||
|     base_path = hostutil.ui_base_path() | ||||
|     return { | ||||
|         "rp_id": pk.rp_id, | ||||
|         "rp_name": pk.rp_name, | ||||
|         "ui_base_path": base_path, | ||||
|         "auth_host": hostutil.configured_auth_host(), | ||||
|     } | ||||
|  | ||||
|  | ||||
| @app.post("/user-info") | ||||
| @@ -193,9 +200,8 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)): | ||||
|         } | ||||
|         effective_permissions = [p.id for p in (ctx.permissions or [])] | ||||
|         is_global_admin = "auth:admin" in (role_info["permissions"] or []) | ||||
|         if org_info: | ||||
|             is_org_admin = f"auth:org:{org_info['uuid']}" in ( | ||||
|                 role_info["permissions"] or [] | ||||
|         is_org_admin = any( | ||||
|             p.startswith("auth:org:") for p in (role_info["permissions"] or []) | ||||
|         ) | ||||
|  | ||||
|     return { | ||||
| @@ -268,8 +274,9 @@ async def api_create_link(request: Request, auth=Cookie(None)): | ||||
|         expires=expires(), | ||||
|         info=session.infodict(request, "device addition"), | ||||
|     ) | ||||
|     origin = global_passkey.instance.origin.rstrip("/") | ||||
|     url = f"{origin}/auth/{token}" | ||||
|     url = hostutil.reset_link_url( | ||||
|         token, request.url.scheme, request.headers.get("host") | ||||
|     ) | ||||
|     return { | ||||
|         "message": "Registration link generated successfully", | ||||
|         "url": url, | ||||
|   | ||||
| @@ -2,11 +2,11 @@ import logging | ||||
| import os | ||||
| from contextlib import asynccontextmanager | ||||
|  | ||||
| from fastapi import FastAPI, HTTPException, Request | ||||
| from fastapi import Cookie, FastAPI, HTTPException | ||||
| from fastapi.responses import FileResponse, RedirectResponse | ||||
| from fastapi.staticfiles import StaticFiles | ||||
|  | ||||
| from passkey.util import frontend, passphrase | ||||
| from passkey.util import frontend, hostutil, passphrase | ||||
|  | ||||
| from . import admin, api, ws | ||||
|  | ||||
| @@ -53,26 +53,37 @@ app.mount( | ||||
|     "/auth/assets/", StaticFiles(directory=frontend.file("assets")), name="assets" | ||||
| ) | ||||
|  | ||||
| # Navigable URLs are defined here. We support both / and /auth/ as the base path | ||||
| # / is used on a dedicated auth site, /auth/ on app domains with auth | ||||
|  | ||||
|  | ||||
| @app.get("/") | ||||
| async def frontapp_redirect(request: Request): | ||||
|     """Redirect root (in case accessed on backend) to the main authentication app.""" | ||||
|     return RedirectResponse(request.url_for("frontapp"), status_code=303) | ||||
|  | ||||
|  | ||||
| @app.get("/auth/") | ||||
| async def frontapp(): | ||||
|     """Serve the main authentication app.""" | ||||
|     return FileResponse(frontend.file("index.html")) | ||||
|  | ||||
|  | ||||
| @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(auth=Cookie(None)): | ||||
|     return await admin.adminapp(auth)  # Delegate to handler of /auth/admin/ | ||||
|  | ||||
|  | ||||
| @app.get("/{reset}") | ||||
| @app.get("/auth/{reset}") | ||||
| async def reset_link(request: Request, reset: str): | ||||
|     """Pretty URL for reset links.""" | ||||
|     if reset == "admin": | ||||
|         # Admin app missing trailing slash lands here, be friendly to user | ||||
|         return RedirectResponse(request.url_for("adminapp"), status_code=303) | ||||
| async def reset_link(reset: str): | ||||
|     """Serve the SPA directly with an injected reset token.""" | ||||
|     if not passphrase.is_well_formed(reset): | ||||
|         raise HTTPException(status_code=404) | ||||
|     url = request.url_for("frontapp").include_query_params(reset=reset) | ||||
|     return RedirectResponse(url, status_code=303) | ||||
|     return FileResponse(frontend.file("reset", "index.html")) | ||||
|  | ||||
|  | ||||
| @app.get("/restricted", include_in_schema=False) | ||||
| @app.get("/auth/restricted", include_in_schema=False) | ||||
| async def restricted_view(): | ||||
|     return FileResponse(frontend.file("restricted", "index.html")) | ||||
|   | ||||
| @@ -17,7 +17,7 @@ from uuid import UUID | ||||
|  | ||||
| from passkey import authsession as _authsession | ||||
| from passkey import globals as _g | ||||
| from passkey.util import passphrase | ||||
| from passkey.util import hostutil, passphrase | ||||
| from passkey.util import tokens as _tokens | ||||
|  | ||||
|  | ||||
| @@ -69,7 +69,7 @@ async def _create_reset(user, role_name: str): | ||||
|         expires=_authsession.expires(), | ||||
|         info={"type": "manual reset", "role": role_name}, | ||||
|     ) | ||||
|     return f"{_g.passkey.instance.origin}/auth/{token}", token | ||||
|     return hostutil.reset_link_url(token), token | ||||
|  | ||||
|  | ||||
| async def _main(query: str | None) -> int: | ||||
|   | ||||
							
								
								
									
										72
									
								
								passkey/util/hostutil.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								passkey/util/hostutil.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| """Utilities for determining the auth UI host and base URLs.""" | ||||
|  | ||||
| import os | ||||
| from functools import lru_cache | ||||
| from urllib.parse import urlparse | ||||
|  | ||||
| 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() | ||||
| @@ -1,10 +1,10 @@ | ||||
| [build-system] | ||||
| requires = ["hatchling"] | ||||
| requires = ["hatchling", "hatch-vcs"] | ||||
| build-backend = "hatchling.build" | ||||
|  | ||||
| [project] | ||||
| name = "passkey" | ||||
| version = "0.1.0" | ||||
| dynamic = ["version"] | ||||
| description = "Passkey Authentication for Web Services" | ||||
| authors = [ | ||||
|     {name = "Leo Vasanko"}, | ||||
| @@ -21,6 +21,12 @@ dependencies = [ | ||||
| ] | ||||
| requires-python = ">=3.10" | ||||
|  | ||||
| [tool.hatch.version] | ||||
| source = "vcs" | ||||
|  | ||||
| [tool.hatch.build.hooks.vcs] | ||||
| version-file = "passkey/_version.py" | ||||
|  | ||||
| [project.optional-dependencies] | ||||
| dev = [ | ||||
|     "ruff>=0.1.0", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user