Compare commits
	
		
			11 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | eb38995cca | ||
|   | 382341e5ee | ||
|   | ed7d3ee0fc | ||
|   | 3dff459068 | ||
|   | 89b40cd080 | ||
|   | d46d50b91a | ||
|   | 39beb31347 | ||
|   | 41e6eb9a5a | ||
|   | d5bc3e773d | ||
|   | ac0256c366 | ||
|   | 6439437e8b | 
| @@ -1,4 +1,5 @@ | |||||||
| localhost { | localhost { | ||||||
|  | 	# Setup the authentication site at /auth/ | ||||||
| 	import auth/setup | 	import auth/setup | ||||||
| 	# Only users with myapp:reports and auth admin permissions | 	# Only users with myapp:reports and auth admin permissions | ||||||
| 	handle_path /reports { | 	handle_path /reports { | ||||||
| @@ -22,16 +23,3 @@ localhost { | |||||||
| 		reverse_proxy :3000 | 		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 | # Permission to use within your endpoints that need authentication/authorization | ||||||
| # is different depending on the route (otherwise use auth/all). | # 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} { | forward_auth {$AUTH_UPSTREAM:localhost:4401} { | ||||||
|     uri /auth/api/forward?{args[0]} |     uri /auth/api/forward?{args[0]} | ||||||
|     header_up Connection keep-alive  # Much higher performance |     header_up Connection keep-alive  # Much higher performance | ||||||
|   | |||||||
| @@ -1,16 +1,26 @@ | |||||||
| <template> | <template> | ||||||
|   <div> |   <div class="app-shell"> | ||||||
|     <StatusMessage /> |     <StatusMessage /> | ||||||
|     <LoginView v-if="store.currentView === 'login'" /> |     <main class="app-main"> | ||||||
|     <ProfileView v-if="store.currentView === 'profile'" /> |       <!-- Only render views after authentication status is determined --> | ||||||
|     <DeviceLinkView v-if="store.currentView === 'device-link'" /> |       <template v-if="initialized"> | ||||||
|     <ResetView v-if="store.currentView === 'reset'" /> |         <LoginView v-if="store.currentView === 'login'" /> | ||||||
|   <PermissionDeniedView v-if="store.currentView === 'permission-denied'" /> |         <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> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup> | <script setup> | ||||||
| import { onMounted } from 'vue' | import { onMounted, ref } from 'vue' | ||||||
| import { useAuthStore } from '@/stores/auth' | import { useAuthStore } from '@/stores/auth' | ||||||
| import StatusMessage from '@/components/StatusMessage.vue' | import StatusMessage from '@/components/StatusMessage.vue' | ||||||
| import LoginView from '@/components/LoginView.vue' | import LoginView from '@/components/LoginView.vue' | ||||||
| @@ -20,6 +30,7 @@ import ResetView from '@/components/ResetView.vue' | |||||||
| import PermissionDeniedView from '@/components/PermissionDeniedView.vue' | import PermissionDeniedView from '@/components/PermissionDeniedView.vue' | ||||||
|  |  | ||||||
| const store = useAuthStore() | const store = useAuthStore() | ||||||
|  | const initialized = ref(false) | ||||||
|  |  | ||||||
| onMounted(async () => { | onMounted(async () => { | ||||||
|   // Detect restricted mode: |   // Detect restricted mode: | ||||||
| @@ -44,16 +55,49 @@ onMounted(async () => { | |||||||
|   if (reset) { |   if (reset) { | ||||||
|     store.resetToken = reset |     store.resetToken = reset | ||||||
|     // Remove query param to avoid lingering in history / clipboard |     // Remove query param to avoid lingering in history / clipboard | ||||||
|   const targetPath = '/auth/' |     const targetPath = '/auth/' | ||||||
|   const currentPath = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/' |     const currentPath = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/' | ||||||
|   history.replaceState(null, '', currentPath.startsWith('/auth') ? '/auth/' : targetPath) |     history.replaceState(null, '', currentPath.startsWith('/auth') ? '/auth/' : targetPath) | ||||||
|   } |   } | ||||||
|   try { |   try { | ||||||
|     await store.loadUserInfo() |     await store.loadUserInfo() | ||||||
|  |     initialized.value = true | ||||||
|  |     store.selectView() | ||||||
|   } catch (error) { |   } catch (error) { | ||||||
|     console.log('Failed to load user info:', error) |     console.log('Failed to load user info:', error) | ||||||
|     store.currentView = 'login' |     store.currentView = 'login' | ||||||
|  |     initialized.value = true | ||||||
|  |     store.selectView() | ||||||
|   } |   } | ||||||
|   store.selectView() |  | ||||||
| }) | }) | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .loading-container { | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|  |   height: 100vh; | ||||||
|  |   gap: 1rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .loading-spinner { | ||||||
|  |   width: 40px; | ||||||
|  |   height: 40px; | ||||||
|  |   border: 4px solid var(--color-border); | ||||||
|  |   border-top: 4px solid var(--color-primary); | ||||||
|  |   border-radius: 50%; | ||||||
|  |   animation: spin 1s linear infinite; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes spin { | ||||||
|  |   0% { transform: rotate(0deg); } | ||||||
|  |   100% { transform: rotate(360deg); } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .loading-container p { | ||||||
|  |   color: var(--color-text-muted); | ||||||
|  |   margin: 0; | ||||||
|  | } | ||||||
|  | </style> | ||||||
|   | |||||||
| @@ -1,9 +1,14 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue' | import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue' | ||||||
|  | import Breadcrumbs from '@/components/Breadcrumbs.vue' | ||||||
| import CredentialList from '@/components/CredentialList.vue' | import CredentialList from '@/components/CredentialList.vue' | ||||||
| import UserBasicInfo from '@/components/UserBasicInfo.vue' | import UserBasicInfo from '@/components/UserBasicInfo.vue' | ||||||
| import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' | import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' | ||||||
| import StatusMessage from '@/components/StatusMessage.vue' | import StatusMessage from '@/components/StatusMessage.vue' | ||||||
|  | import AdminOverview from './AdminOverview.vue' | ||||||
|  | import AdminOrgDetail from './AdminOrgDetail.vue' | ||||||
|  | import AdminUserDetail from './AdminUserDetail.vue' | ||||||
|  | import AdminDialogs from './AdminDialogs.vue' | ||||||
| import { useAuthStore } from '@/stores/auth' | import { useAuthStore } from '@/stores/auth' | ||||||
|  |  | ||||||
| const info = ref(null) | const info = ref(null) | ||||||
| @@ -19,15 +24,13 @@ const userLinkExpires = ref(null) | |||||||
| const authStore = useAuthStore() | const authStore = useAuthStore() | ||||||
| const addingOrgForPermission = ref(null) | const addingOrgForPermission = ref(null) | ||||||
| const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$' | const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$' | ||||||
| const showCreatePermission = ref(false) |  | ||||||
| const newPermId = ref('') |  | ||||||
| const newPermName = ref('') |  | ||||||
| const editingPermId = ref(null) | const editingPermId = ref(null) | ||||||
| const renameIdValue = ref('') | const renameIdValue = ref('') | ||||||
|  | const editingPermDisplay = ref(null) | ||||||
|  | const renameDisplayValue = ref('') | ||||||
| const dialog = ref({ type: null, data: null, busy: false, error: '' }) | const dialog = ref({ type: null, data: null, busy: false, error: '' }) | ||||||
| const safeIdRegex = /[^A-Za-z0-9:._~-]/g | const safeIdRegex = /[^A-Za-z0-9:._~-]/g | ||||||
|  |  | ||||||
| function sanitizeNewId() { if (newPermId.value) newPermId.value = newPermId.value.replace(safeIdRegex, '') } |  | ||||||
| function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') } | function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') } | ||||||
|  |  | ||||||
| function handleGlobalClick(e) { | function handleGlobalClick(e) { | ||||||
| @@ -51,7 +54,9 @@ const permissionSummary = computed(() => { | |||||||
|   const summary = {} |   const summary = {} | ||||||
|   for (const o of orgs.value) { |   for (const o of orgs.value) { | ||||||
|     const orgBase = { uuid: o.uuid, display_name: o.display_name } |     const orgBase = { uuid: o.uuid, display_name: o.display_name } | ||||||
|     // Org-level permissions (direct) |     const orgPerms = new Set(o.permissions || []) | ||||||
|  |      | ||||||
|  |     // Org-level permissions (direct) - only count if org can grant them | ||||||
|     for (const pid of o.permissions || []) { |     for (const pid of o.permissions || []) { | ||||||
|       if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } |       if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } | ||||||
|       if (!summary[pid].orgSet.has(o.uuid)) { |       if (!summary[pid].orgSet.has(o.uuid)) { | ||||||
| @@ -59,9 +64,13 @@ const permissionSummary = computed(() => { | |||||||
|         summary[pid].orgSet.add(o.uuid) |         summary[pid].orgSet.add(o.uuid) | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     // Role-based permissions (inheritance) |      | ||||||
|  |     // Role-based permissions (inheritance) - only count if org can grant them | ||||||
|     for (const r of o.roles) { |     for (const r of o.roles) { | ||||||
|       for (const pid of r.permissions) { |       for (const pid of r.permissions) { | ||||||
|  |         // Only count if the org can grant this permission | ||||||
|  |         if (!orgPerms.has(pid)) continue | ||||||
|  |          | ||||||
|         if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } |         if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } | ||||||
|         if (!summary[pid].orgSet.has(o.uuid)) { |         if (!summary[pid].orgSet.has(o.uuid)) { | ||||||
|           summary[pid].orgs.push(orgBase) |           summary[pid].orgs.push(orgBase) | ||||||
| @@ -78,25 +87,7 @@ const permissionSummary = computed(() => { | |||||||
|   return display |   return display | ||||||
| }) | }) | ||||||
|  |  | ||||||
| function availableOrgsForPermission(pid) { | function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p, id: p.id, display_name: p.display_name }) } | ||||||
|   return orgs.value.filter(o => !o.permissions.includes(pid)) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p }) } |  | ||||||
|  |  | ||||||
| function startRenamePermissionId(p) { editingPermId.value = p.id; renameIdValue.value = p.id } |  | ||||||
| function cancelRenameId() { editingPermId.value = null; renameIdValue.value = '' } |  | ||||||
| async function submitRenamePermissionId(p) { |  | ||||||
|   const newId = renameIdValue.value.trim() |  | ||||||
|   if (!newId || newId === p.id) { cancelRenameId(); return } |  | ||||||
|   try { |  | ||||||
|     const body = { old_id: p.id, new_id: newId, display_name: p.display_name } |  | ||||||
|     const res = await fetch('/auth/admin/permission/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) |  | ||||||
|     let data; try { data = await res.json() } catch(_) { data = {} } |  | ||||||
|     if (!res.ok || data.detail) throw new Error(data.detail || data.error || `Failed (${res.status})`) |  | ||||||
|     await refreshPermissionsContext(); cancelRenameId() |  | ||||||
|   } catch (e) { authStore.showMessage(e?.message || 'Rename failed') } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| async function refreshPermissionsContext() { | async function refreshPermissionsContext() { | ||||||
|   // Reload both lists so All Permissions table shows new associations promptly. |   // Reload both lists so All Permissions table shows new associations promptly. | ||||||
| @@ -179,6 +170,7 @@ async function load() { | |||||||
|       if (!window.location.hash || window.location.hash === '#overview') { |       if (!window.location.hash || window.location.hash === '#overview') { | ||||||
|         currentOrgId.value = orgs.value[0].uuid |         currentOrgId.value = orgs.value[0].uuid | ||||||
|         window.location.hash = `#org/${currentOrgId.value}` |         window.location.hash = `#org/${currentOrgId.value}` | ||||||
|  |         authStore.showMessage(`Navigating to ${orgs.value[0].display_name} Administration`, 'info', 3000) | ||||||
|       } else { |       } else { | ||||||
|         parseHash() |         parseHash() | ||||||
|       } |       } | ||||||
| @@ -193,14 +185,16 @@ async function load() { | |||||||
| // Org actions | // Org actions | ||||||
| function createOrg() { openDialog('org-create', {}) } | function createOrg() { openDialog('org-create', {}) } | ||||||
|  |  | ||||||
| function updateOrg(org) { openDialog('org-update', { org }) } | function updateOrg(org) { openDialog('org-update', { org, name: org.display_name }) } | ||||||
|  |  | ||||||
|  | function editUserName(user) { openDialog('user-update-name', { user, name: user.display_name }) } | ||||||
|  |  | ||||||
| function deleteOrg(org) { | function deleteOrg(org) { | ||||||
|   if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return } |   if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return } | ||||||
|   openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => { |   openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => { | ||||||
|     const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' }) |     const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' }) | ||||||
|     const data = await res.json(); if (data.detail) throw new Error(data.detail) |     const data = await res.json(); if (data.detail) throw new Error(data.detail) | ||||||
|     await loadOrgs() |     await Promise.all([loadOrgs(), loadPermissions()]) | ||||||
|   } }) |   } }) | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -246,7 +240,7 @@ async function removeOrgPermission() { /* obsolete */ } | |||||||
| // Role actions | // Role actions | ||||||
| function createRole(org) { openDialog('role-create', { org }) } | function createRole(org) { openDialog('role-create', { org }) } | ||||||
|  |  | ||||||
| function updateRole(role) { openDialog('role-update', { role }) } | function updateRole(role) { openDialog('role-update', { role, name: role.display_name }) } | ||||||
|  |  | ||||||
| function deleteRole(role) { | function deleteRole(role) { | ||||||
|   openDialog('confirm', { message: `Delete role ${role.display_name}?`, action: async () => { |   openDialog('confirm', { message: `Delete role ${role.display_name}?`, action: async () => { | ||||||
| @@ -256,17 +250,32 @@ function deleteRole(role) { | |||||||
|   } }) |   } }) | ||||||
| } | } | ||||||
|  |  | ||||||
| // Permission actions | async function toggleRolePermission(role, pid, checked) { | ||||||
| async function submitCreatePermission() { |   // Calculate new permissions array | ||||||
|   const id = newPermId.value.trim() |   const newPermissions = checked  | ||||||
|   const name = newPermName.value.trim() |     ? [...role.permissions, pid]  | ||||||
|   if (!id || !name) return |     : role.permissions.filter(p => p !== pid) | ||||||
|   const res = await fetch('/auth/admin/permissions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, display_name: name }) }) |    | ||||||
|   const data = await res.json(); if (data.detail) { authStore.showMessage(data.detail); return } |   // Optimistic update | ||||||
|   await loadPermissions(); newPermId.value=''; newPermName.value=''; showCreatePermission.value=false |   const prevPermissions = [...role.permissions] | ||||||
|  |   role.permissions = newPermissions | ||||||
|  |    | ||||||
|  |   try { | ||||||
|  |     const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { | ||||||
|  |       method: 'PUT', | ||||||
|  |       headers: { 'content-type': 'application/json' }, | ||||||
|  |       body: JSON.stringify({ display_name: role.display_name, permissions: newPermissions }) | ||||||
|  |     }) | ||||||
|  |     const data = await res.json() | ||||||
|  |     if (data.detail) throw new Error(data.detail) | ||||||
|  |     await loadOrgs() | ||||||
|  |   } catch (e) { | ||||||
|  |     authStore.showMessage(e.message || 'Failed to update role permission') | ||||||
|  |     role.permissions = prevPermissions // revert | ||||||
|  |   } | ||||||
| } | } | ||||||
| function cancelCreatePermission() { newPermId.value=''; newPermName.value=''; showCreatePermission.value=false } |  | ||||||
|  |  | ||||||
|  | // Permission actions | ||||||
| function updatePermission(p) { openDialog('perm-display', { permission: p }) } | function updatePermission(p) { openDialog('perm-display', { permission: p }) } | ||||||
|  |  | ||||||
| function deletePermission(p) { | function deletePermission(p) { | ||||||
| @@ -313,9 +322,30 @@ const selectedUser = computed(() => { | |||||||
| }) | }) | ||||||
|  |  | ||||||
| const pageHeading = computed(() => { | const pageHeading = computed(() => { | ||||||
|   if (selectedUser.value) return 'Organization Admin' |   if (selectedUser.value) return 'Admin: User' | ||||||
|   if (selectedOrg.value) return 'Organization Admin' |   if (selectedOrg.value) return 'Admin: Org' | ||||||
|   return (authStore.settings?.rp_name || 'Passkey') + ' Admin' |   return (authStore.settings?.rp_name || 'Master') + ' Admin' | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | // Breadcrumb entries for admin app. | ||||||
|  | const breadcrumbEntries = computed(() => { | ||||||
|  |   const entries = [ | ||||||
|  |     { label: 'Auth', href: '/auth/' }, | ||||||
|  |     { label: 'Admin', href: '/auth/admin/' } | ||||||
|  |   ] | ||||||
|  |   // Determine organization for user view if selectedOrg not explicitly chosen. | ||||||
|  |   let orgForUser = null | ||||||
|  |   if (selectedUser.value) { | ||||||
|  |     orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org_uuid) || null | ||||||
|  |   } | ||||||
|  |   const orgToShow = selectedOrg.value || orgForUser | ||||||
|  |   if (orgToShow) { | ||||||
|  |     entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` }) | ||||||
|  |   } | ||||||
|  |   if (selectedUser.value) { | ||||||
|  |     entries.push({ label: selectedUser.value.display_name || 'User', href: `#user/${selectedUser.value.uuid}` }) | ||||||
|  |   } | ||||||
|  |   return entries | ||||||
| }) | }) | ||||||
|  |  | ||||||
| watch(selectedUser, async (u) => { | watch(selectedUser, async (u) => { | ||||||
| @@ -349,26 +379,24 @@ function permissionDisplayName(id) { | |||||||
|   return permissions.value.find(p => p.id === id)?.display_name || id |   return permissions.value.find(p => p.id === id)?.display_name || id | ||||||
| } | } | ||||||
|  |  | ||||||
| async function toggleRolePermission(role, permId, checked) { | async function toggleOrgPermission(org, permId, checked) { | ||||||
|   // Build next permission list |   // Build next permission list | ||||||
|   const has = role.permissions.includes(permId) |   const has = org.permissions.includes(permId) | ||||||
|   if (checked && has) return |   if (checked && has) return | ||||||
|   if (!checked && !has) return |   if (!checked && !has) return | ||||||
|   const next = checked ? [...role.permissions, permId] : role.permissions.filter(p => p !== permId) |   const next = checked ? [...org.permissions, permId] : org.permissions.filter(p => p !== permId) | ||||||
|   // Optimistic update |   // Optimistic update | ||||||
|   const prev = [...role.permissions] |   const prev = [...org.permissions] | ||||||
|   role.permissions = next |   org.permissions = next | ||||||
|   try { |   try { | ||||||
|   const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { |     const params = new URLSearchParams({ permission_id: permId }) | ||||||
|       method: 'PUT', |     const res = await fetch(`/auth/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' }) | ||||||
|       headers: { 'content-type': 'application/json' }, |  | ||||||
|       body: JSON.stringify({ display_name: role.display_name, permissions: next }) |  | ||||||
|     }) |  | ||||||
|     const data = await res.json() |     const data = await res.json() | ||||||
|     if (data.detail) throw new Error(data.detail) |     if (data.detail) throw new Error(data.detail) | ||||||
|  |     await loadOrgs() | ||||||
|   } catch (e) { |   } catch (e) { | ||||||
|     authStore.showMessage(e.message || 'Failed to update role permission') |     authStore.showMessage(e.message || 'Failed to update organization permission') | ||||||
|     role.permissions = prev // revert |     org.permissions = prev // revert | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -407,19 +435,42 @@ async function submitDialog() { | |||||||
|       const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs() |       const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs() | ||||||
|     } else if (t === 'role-update') { |     } else if (t === 'role-update') { | ||||||
|       const { role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required') |       const { role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required') | ||||||
|       const permsCsv = dialog.value.data.perms || '' |       const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: role.permissions }) }) | ||||||
|       const perms = permsCsv.split(',').map(s=>s.trim()).filter(Boolean) |  | ||||||
|   const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: perms }) }) |  | ||||||
|       const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs() |       const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs() | ||||||
|     } else if (t === 'user-create') { |     } else if (t === 'user-create') { | ||||||
|       const { org, role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required') |       const { org, role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required') | ||||||
|       const res = await fetch(`/auth/admin/orgs/${org.uuid}/users`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, role: role.display_name }) }) |       const res = await fetch(`/auth/admin/orgs/${org.uuid}/users`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, role: role.display_name }) }) | ||||||
|       const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs() |       const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs() | ||||||
|  |     } else if (t === 'user-update-name') { | ||||||
|  |       const { user } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required') | ||||||
|  |       const res = await fetch(`/auth/admin/orgs/${user.org_uuid}/users/${user.uuid}/display-name`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name }) }) | ||||||
|  |       const d = await res.json(); if (d.detail) throw new Error(d.detail); await onUserNameSaved() | ||||||
|     } else if (t === 'perm-display') { |     } else if (t === 'perm-display') { | ||||||
|       const { permission } = dialog.value.data; const display = dialog.value.data.display_name?.trim(); if (!display) throw new Error('Display name required') |       const { permission } = dialog.value.data | ||||||
|       const params = new URLSearchParams({ permission_id: permission.id, display_name: display }) |       const newId = dialog.value.data.id?.trim() | ||||||
|       const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'PUT' }) |       const newDisplay = dialog.value.data.display_name?.trim() | ||||||
|       const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadPermissions() |       if (!newDisplay) throw new Error('Display name required') | ||||||
|  |       if (!newId) throw new Error('ID required') | ||||||
|  |        | ||||||
|  |       if (newId !== permission.id) { | ||||||
|  |         // ID changed, use rename endpoint | ||||||
|  |         const body = { old_id: permission.id, new_id: newId, display_name: newDisplay } | ||||||
|  |         const res = await fetch('/auth/admin/permission/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) | ||||||
|  |         let data; try { data = await res.json() } catch(_) { data = {} } | ||||||
|  |         if (!res.ok || data.detail) throw new Error(data.detail || data.error || `Failed (${res.status})`) | ||||||
|  |       } else if (newDisplay !== permission.display_name) { | ||||||
|  |         // Only display name changed | ||||||
|  |         const params = new URLSearchParams({ permission_id: permission.id, display_name: newDisplay }) | ||||||
|  |         const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'PUT' }) | ||||||
|  |         const d = await res.json(); if (d.detail) throw new Error(d.detail) | ||||||
|  |       } | ||||||
|  |       await loadPermissions() | ||||||
|  |     } else if (t === 'perm-create') { | ||||||
|  |       const id = dialog.value.data.id?.trim(); if (!id) throw new Error('ID required') | ||||||
|  |       const display_name = dialog.value.data.display_name?.trim(); if (!display_name) throw new Error('Display name required') | ||||||
|  |       const res = await fetch('/auth/admin/permissions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, display_name }) }) | ||||||
|  |       const data = await res.json(); if (data.detail) throw new Error(data.detail) | ||||||
|  |       await loadPermissions(); dialog.value.data.display_name = ''; dialog.value.data.id = '' | ||||||
|     } else if (t === 'confirm') { |     } else if (t === 'confirm') { | ||||||
|       const action = dialog.value.data.action; if (action) await action() |       const action = dialog.value.data.action; if (action) await action() | ||||||
|     } |     } | ||||||
| @@ -431,447 +482,94 @@ async function submitDialog() { | |||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <div class="container"> |   <div class="app-shell admin-shell"> | ||||||
|     <h1> |     <StatusMessage /> | ||||||
|       {{ pageHeading }} |     <main class="app-main"> | ||||||
|       <a href="/auth/" class="back-link" title="Back to User App">User</a> |       <section class="view-root view-admin"> | ||||||
|       <a |         <div class="view-content view-content--wide"> | ||||||
|         v-if="info?.is_global_admin && (selectedOrg || selectedUser)" |           <header class="view-header"> | ||||||
|         @click.prevent="goOverview" |             <h1>{{ pageHeading }}</h1> | ||||||
|         href="#overview" |             <Breadcrumbs :entries="breadcrumbEntries" /> | ||||||
|         class="nav-link" |           </header> | ||||||
|         title="Back to overview" |  | ||||||
|       >Overview</a> |  | ||||||
|     </h1> |  | ||||||
|     <div v-if="loading">Loading…</div> |  | ||||||
|     <div v-else-if="error" class="error">{{ error }}</div> |  | ||||||
|     <div v-else> |  | ||||||
|       <div v-if="!info?.authenticated"> |  | ||||||
|         <p>You must be authenticated.</p> |  | ||||||
|       </div> |  | ||||||
|       <div v-else-if="!(info?.is_global_admin || info?.is_org_admin)"> |  | ||||||
|         <p>Insufficient permissions.</p> |  | ||||||
|       </div> |  | ||||||
|       <div v-else> |  | ||||||
|  |  | ||||||
|   <!-- Removed user-specific info (current org, effective permissions, admin flags) --> |           <section class="section-block admin-section"> | ||||||
|  |             <div class="section-body admin-section-body"> | ||||||
|         <!-- Overview Page --> |               <div v-if="loading" class="surface surface--tight">Loading…</div> | ||||||
|   <div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card"> |               <div v-else-if="error" class="surface surface--tight error">{{ error }}</div> | ||||||
|           <h2>Organizations</h2> |               <template v-else> | ||||||
|           <div class="actions"> |                 <div v-if="!info?.authenticated" class="surface surface--tight"> | ||||||
|             <button @click="createOrg" v-if="info.is_global_admin">+ Create Org</button> |                   <p>You must be authenticated.</p> | ||||||
|           </div> |  | ||||||
|           <table class="org-table"> |  | ||||||
|             <thead> |  | ||||||
|               <tr> |  | ||||||
|                 <th>Name</th> |  | ||||||
|                 <th>Roles</th> |  | ||||||
|                  <th>Members</th> |  | ||||||
|                 <th v-if="info.is_global_admin">Actions</th> |  | ||||||
|               </tr> |  | ||||||
|             </thead> |  | ||||||
|             <tbody> |  | ||||||
|               <tr v-for="o in orgs" :key="o.uuid"> |  | ||||||
|                 <td><a href="#org/{{o.uuid}}" @click.prevent="openOrg(o)">{{ o.display_name }}</a></td> |  | ||||||
|                 <td>{{ o.roles.length }}</td> |  | ||||||
|                 <td>{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td> |  | ||||||
|                 <td v-if="info.is_global_admin"> |  | ||||||
|                   <button @click="updateOrg(o)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button> |  | ||||||
|                   <button @click="deleteOrg(o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization">❌</button> |  | ||||||
|                 </td> |  | ||||||
|               </tr> |  | ||||||
|             </tbody> |  | ||||||
|           </table> |  | ||||||
|         </div> |  | ||||||
|  |  | ||||||
|         <!-- User Detail Page --> |  | ||||||
|         <div v-if="selectedUser" class="card user-detail"> |  | ||||||
|           <UserBasicInfo |  | ||||||
|             v-if="userDetail && !userDetail.error" |  | ||||||
|             :name="userDetail.display_name || selectedUser.display_name" |  | ||||||
|             :visits="userDetail.visits" |  | ||||||
|             :created-at="userDetail.created_at" |  | ||||||
|             :last-seen="userDetail.last_seen" |  | ||||||
|             :loading="loading" |  | ||||||
|             :org-display-name="userDetail.org.display_name" |  | ||||||
|             :role-name="userDetail.role" |  | ||||||
|             :update-endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`" |  | ||||||
|             @saved="onUserNameSaved" |  | ||||||
|           /> |  | ||||||
|           <div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div> |  | ||||||
|           <template v-if="userDetail && !userDetail.error"> |  | ||||||
|             <h3 class="cred-title">Registered Passkeys</h3> |  | ||||||
|             <CredentialList :credentials="userDetail.credentials" :aaguid-info="userDetail.aaguid_info" /> |  | ||||||
|           </template> |  | ||||||
|           <div class="actions"> |  | ||||||
|             <button @click="generateUserRegistrationLink(selectedUser)">Generate Registration Token</button> |  | ||||||
|             <button @click="goOverview" v-if="info.is_global_admin" class="icon-btn" title="Overview">🏠</button> |  | ||||||
|             <button @click="openOrg(selectedOrg)" v-if="selectedOrg" class="icon-btn" title="Back to Org">↩️</button> |  | ||||||
|           </div> |  | ||||||
|           <p class="matrix-hint muted">Use the token dialog to register a new credential for the member.</p> |  | ||||||
|           <RegistrationLinkModal |  | ||||||
|             v-if="showRegModal" |  | ||||||
|             :endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`" |  | ||||||
|             :auto-copy="false" |  | ||||||
|             @close="showRegModal = false" |  | ||||||
|             @copied="onLinkCopied" |  | ||||||
|           /> |  | ||||||
|         </div> |  | ||||||
|  |  | ||||||
|         <!-- Organization Detail Page --> |  | ||||||
|         <div v-else-if="selectedOrg" class="card"> |  | ||||||
|           <h2 class="org-title" :title="selectedOrg.uuid"> |  | ||||||
|             <span class="org-name">{{ selectedOrg.display_name }}</span> |  | ||||||
|             <button @click="updateOrg(selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button> |  | ||||||
|           </h2> |  | ||||||
|           <div class="org-actions"></div> |  | ||||||
|  |  | ||||||
|           <div class="matrix-wrapper"> |  | ||||||
|             <div class="matrix-scroll"> |  | ||||||
|               <div |  | ||||||
|                 class="perm-matrix-grid" |  | ||||||
|                 :style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + selectedOrg.roles.map(()=> '2.2rem').join(' ') + ' 2.2rem' }" |  | ||||||
|               > |  | ||||||
|                 <!-- Headers --> |  | ||||||
|                 <div class="grid-head perm-head">Permission</div> |  | ||||||
|                 <div |  | ||||||
|                   v-for="r in selectedOrg.roles" |  | ||||||
|                   :key="'head-' + r.uuid" |  | ||||||
|                   class="grid-head role-head" |  | ||||||
|                   :title="r.display_name" |  | ||||||
|                 > |  | ||||||
|                   <span>{{ r.display_name }}</span> |  | ||||||
|                 </div> |                 </div> | ||||||
|                 <div class="grid-head role-head add-role-head" title="Add role" @click="createRole(selectedOrg)" role="button">➕</div> |                 <div v-else-if="!(info?.is_global_admin || info?.is_org_admin)" class="surface surface--tight"> | ||||||
|  |                   <p>Insufficient permissions.</p> | ||||||
|                 <!-- Data Rows --> |                 </div> | ||||||
|                 <template v-for="pid in selectedOrg.permissions" :key="pid"> |                 <div v-else class="admin-panels"> | ||||||
|                   <div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div> |                                     <AdminOverview | ||||||
|                   <div |                     v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" | ||||||
|                     v-for="r in selectedOrg.roles" |                     :info="info" | ||||||
|                     :key="r.uuid + '-' + pid" |                     :orgs="orgs" | ||||||
|                     class="matrix-cell" |                     :permissions="permissions" | ||||||
|                   > |                     :permission-summary="permissionSummary" | ||||||
|                     <input |                     @create-org="createOrg" | ||||||
|                       type="checkbox" |                     @open-org="openOrg" | ||||||
|                       :checked="r.permissions.includes(pid)" |                     @update-org="updateOrg" | ||||||
|                       @change="e => toggleRolePermission(r, pid, e.target.checked)" |                     @delete-org="deleteOrg" | ||||||
|                     /> |                     @toggle-org-permission="toggleOrgPermission" | ||||||
|                   </div> |                     @open-dialog="openDialog" | ||||||
|                   <div class="matrix-cell add-role-cell" /> |                     @delete-permission="deletePermission" | ||||||
|                 </template> |                     @rename-permission-display="renamePermissionDisplay" | ||||||
|               </div> |                   /> | ||||||
|             </div> |  | ||||||
|             <p class="matrix-hint muted">Toggle which permissions each role grants.</p> |                   <AdminUserDetail | ||||||
|           </div> |                     v-else-if="selectedUser" | ||||||
|           <div class="roles-grid"> |                     :selected-user="selectedUser" | ||||||
|             <div |                     :user-detail="userDetail" | ||||||
|               v-for="r in selectedOrg.roles" |                     :selected-org="selectedOrg" | ||||||
|               :key="r.uuid" |                     :loading="loading" | ||||||
|               class="role-column" |                     :show-reg-modal="showRegModal" | ||||||
|               @dragover="onRoleDragOver" |                     @generate-user-registration-link="generateUserRegistrationLink" | ||||||
|               @drop="e => onRoleDrop(e, selectedOrg, r)" |                     @go-overview="goOverview" | ||||||
|             > |                     @open-org="openOrg" | ||||||
|               <div class="role-header"> |                     @on-user-name-saved="onUserNameSaved" | ||||||
|                 <strong class="role-name" :title="r.uuid"> |                     @edit-user-name="editUserName" | ||||||
|                   <span>{{ r.display_name }}</span> |                     @close-reg-modal="showRegModal = false" | ||||||
|                   <button @click="updateRole(r)" class="icon-btn" aria-label="Edit role" title="Edit role">✏️</button> |                   /> | ||||||
|                 </strong> |                   <AdminOrgDetail | ||||||
|                 <div class="role-actions"> |                     v-else-if="selectedOrg" | ||||||
|                   <button @click="createUserInRole(selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user">➕</button> |                     :selected-org="selectedOrg" | ||||||
|  |                     :permissions="permissions" | ||||||
|  |                     @update-org="updateOrg" | ||||||
|  |                     @create-role="createRole" | ||||||
|  |                     @update-role="updateRole" | ||||||
|  |                     @delete-role="deleteRole" | ||||||
|  |                     @create-user-in-role="createUserInRole" | ||||||
|  |                     @open-user="openUser" | ||||||
|  |                     @toggle-role-permission="toggleRolePermission" | ||||||
|  |                     @on-role-drag-over="onRoleDragOver" | ||||||
|  |                     @on-role-drop="onRoleDrop" | ||||||
|  |                     @on-user-drag-start="onUserDragStart" | ||||||
|  |                   /> | ||||||
|  |  | ||||||
|                 </div> |                 </div> | ||||||
|               </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> |               </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> |           </section> | ||||||
|         </div> |         </div> | ||||||
|  |       </section> | ||||||
|   <div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card"> |     </main> | ||||||
|           <h2>All Permissions</h2> |     <AdminDialogs | ||||||
|           <div class="actions"> |       :dialog="dialog" | ||||||
|             <button v-if="!showCreatePermission" @click="showCreatePermission = true">+ Create Permission</button> |       :permission-id-pattern="PERMISSION_ID_PATTERN" | ||||||
|             <form v-else class="inline-form" @submit.prevent="submitCreatePermission"> |       @submit-dialog="submitDialog" | ||||||
|               <input v-model="newPermId" @input="sanitizeNewId" required :pattern="PERMISSION_ID_PATTERN" placeholder="permission id" title="Allowed: A-Za-z0-9:._~-" /> |       @close-dialog="closeDialog" | ||||||
|               <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> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <style scoped> | <style scoped> | ||||||
| .container { max-width: 960px; margin: 2rem auto; padding: 0 1rem; } | .view-admin { padding-bottom: var(--space-3xl); } | ||||||
| .subtitle { color: #888 } | .view-header { display: flex; flex-direction: column; gap: var(--space-sm); } | ||||||
| .card { margin: 1rem 0; padding: 1rem; border: 1px solid #eee; border-radius: 8px; } | .admin-section { margin-top: var(--space-xl); } | ||||||
| .error { color: #a00 } | .admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); } | ||||||
| .actions { margin-bottom: .5rem } | .admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); } | ||||||
| .org { border-top: 1px dashed #eee; padding: .5rem 0 } |  | ||||||
| .org-header { display: flex; gap: .5rem; align-items: baseline } |  | ||||||
| .user-item { display: flex; gap: .5rem; margin: .15rem 0 } |  | ||||||
| .users-table { width: 100%; border-collapse: collapse; margin-top: .25rem; } |  | ||||||
| .users-table th, .users-table td { padding: .25rem .4rem; text-align: left; border-bottom: 1px solid #eee; font-weight: normal; } |  | ||||||
| .users-table th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: #555; } |  | ||||||
| .users-table tbody tr:hover { background: #fafafa; } |  | ||||||
| .org-actions, .role-actions, .perm-actions { display: flex; gap: .5rem; margin: .25rem 0 } |  | ||||||
| .muted { color: #666 } |  | ||||||
| .small { font-size: .9em } |  | ||||||
| .pill-list { display: flex; flex-wrap: wrap; gap: .25rem } |  | ||||||
| .pill { background: #f3f3f3; border: 1px solid #e2e2e2; border-radius: 999px; padding: .1rem .5rem; display: inline-flex; align-items: center; gap: .25rem } |  | ||||||
| .pill-x { background: transparent; border: none; color: #900; cursor: pointer } |  | ||||||
| button { padding: .25rem .5rem; border-radius: 6px; border: 1px solid #ddd; background: #fff; cursor: pointer } |  | ||||||
| button:hover { background: #f7f7f7 } |  | ||||||
| /* Avoid global button 100% width from frontend main styles */ |  | ||||||
| button, .perm-actions button, .org-actions button, .role-actions button { width: auto; } |  | ||||||
| .roles-grid { display: flex; flex-wrap: wrap; gap: 1rem; align-items: stretch; padding: .5rem 0; } |  | ||||||
| .role-column { background: #fafafa; border: 1px solid #eee; border-radius: 8px; padding: .5rem; min-width: 200px; flex: 1 1 240px; display: flex; flex-direction: column; max-width: 300px; } |  | ||||||
| .role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .25rem } |  | ||||||
| .user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .25rem; flex: 1 1 auto; } |  | ||||||
| .user-chip { background: #fff; border: 1px solid #ddd; border-radius: 6px; padding: .25rem .4rem; display: flex; justify-content: space-between; gap: .5rem; cursor: grab; } |  | ||||||
| .user-chip:active { cursor: grabbing } |  | ||||||
| .user-chip .name { font-weight: 500 } |  | ||||||
| .user-chip .meta { font-size: .65rem; color: #666 } |  | ||||||
| .role-column.drag-over { outline: 2px dashed #66a; } |  | ||||||
| .org-table { width: 100%; border-collapse: collapse; } |  | ||||||
| .org-table th, .org-table td { padding: .4rem .5rem; border-bottom: 1px solid #eee; text-align: left; } |  | ||||||
| .org-table th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: #555; } |  | ||||||
| .org-table a { text-decoration: none; color: #0366d6; } |  | ||||||
| .org-table a:hover { text-decoration: underline; } |  | ||||||
| .nav-link { font-size: .6em; margin-left: .5rem; background: #eee; padding: .25em .6em; border-radius: 999px; border: 1px solid #ccc; text-decoration: none; } |  | ||||||
| .nav-link:hover { background: #ddd; } |  | ||||||
| .back-link { font-size: .5em; margin-left: .75rem; text-decoration: none; background: #eee; padding: .25em .6em; border-radius: 999px; border: 1px solid #ccc; vertical-align: middle; line-height: 1.2; } |  | ||||||
| .back-link:hover { background: #ddd; } |  | ||||||
| .matrix-wrapper { margin: 1rem 0; text-align: left; } |  | ||||||
| .matrix-scroll { overflow-x: auto; text-align: left; } |  | ||||||
| .perm-matrix-grid { display: inline-grid; gap: 0; align-items: stretch; margin-right: 4rem; } |  | ||||||
| .perm-matrix-grid > * { background: #fff; border: none; padding: .35rem .4rem; font-size: .75rem; } |  | ||||||
| .perm-matrix-grid .grid-head { background: transparent; border: none; font-size: .65rem; letter-spacing: .05em; font-weight: 600; text-transform: uppercase; display: flex; justify-content: center; align-items: flex-end; padding-bottom: .25rem; } |  | ||||||
| .perm-matrix-grid .perm-head { justify-content: flex-start; align-items: flex-end; } |  | ||||||
| .perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: .6rem; line-height: 1; } |  | ||||||
| .perm-matrix-grid .perm-name { font-weight: 500; white-space: nowrap; text-align: left; } |  | ||||||
| .perm-matrix-grid .matrix-cell { display: flex; justify-content: center; align-items: center; } |  | ||||||
| .perm-matrix-grid .matrix-cell input { cursor: pointer; } |  | ||||||
| .matrix-hint { font-size: .7rem; margin-top: .25rem; } |  | ||||||
| /* Add role column styles */ |  | ||||||
| .add-role-head { cursor: pointer; color: #2a6; font-size: 1rem; display:flex; justify-content:center; align-items:flex-end; } |  | ||||||
| .add-role-head:hover { color:#1c4; } |  | ||||||
| /* Removed add-role placeholder styles */ |  | ||||||
| /* Inline organization title with icon */ |  | ||||||
| .org-title { display: flex; align-items: center; gap: .4rem; } |  | ||||||
| .org-title .org-name { flex: 0 1 auto; } |  | ||||||
| /* Plus button for adding users */ |  | ||||||
| .plus-btn { background: none; border: none; font-size: 1.15rem; line-height: 1; padding: 0 .1rem; cursor: pointer; opacity: .6; } |  | ||||||
| .plus-btn:hover, .plus-btn:focus { opacity: 1; outline: none; } |  | ||||||
| .plus-btn:focus-visible { outline: 2px solid #555; outline-offset: 2px; } |  | ||||||
| .empty-role { display: flex; flex-direction: column; gap: .4rem; align-items: flex-start; padding: .35rem .25rem; /* removed flex grow & width for natural size */ } |  | ||||||
| .empty-role .empty-text { font-size: .7rem; margin: 0; } |  | ||||||
| .delete-icon { color: #c00; } |  | ||||||
| .delete-icon:hover, .delete-icon:focus { color: #ff0000; } |  | ||||||
| .user-detail .user-link-box { margin-top: .75rem; font-size: .7rem; background: #fff; border: 1px dashed #ccc; padding: .5rem; border-radius: 6px; cursor: pointer; word-break: break-all; } |  | ||||||
| .user-detail .user-link-box:hover { background: #f9f9f9; } |  | ||||||
| .user-detail .user-link-box .expires { font-size: .6rem; margin-top: .25rem; color: #555; } |  | ||||||
| /* Minimal icon button for rename/edit actions */ |  | ||||||
| .icon-btn { background: none; border: none; padding: 0 .15rem; margin-left: .15rem; cursor: pointer; font-size: .8rem; line-height: 1; opacity: .55; vertical-align: middle; } |  | ||||||
| .icon-btn:hover, .icon-btn:focus { opacity: .95; outline: none; } |  | ||||||
| .icon-btn:focus-visible { outline: 2px solid #555; outline-offset: 2px; } |  | ||||||
| .icon-btn:active { transform: translateY(1px); } |  | ||||||
| .org-title { display: flex; align-items: baseline; gap: .25rem; } |  | ||||||
| .role-name { display: inline-flex; align-items: center; gap: .15rem; font-weight: 600; } |  | ||||||
| .perm-name-line { display: flex; align-items: center; gap: .15rem; } |  | ||||||
| .user-meta { margin-top: .25rem; } |  | ||||||
| .cred-title { margin-top: .75rem; font-size: .85rem; } |  | ||||||
| .cred-list { list-style: none; padding: 0; margin: .25rem 0 .5rem; display: flex; flex-direction: column; gap: .35rem; } |  | ||||||
| .cred-item { background: #fff; border: 1px solid #eee; border-radius: 6px; padding: .35rem .5rem; font-size: .65rem; } |  | ||||||
| .cred-line { display: flex; flex-direction: column; gap: .15rem; } |  | ||||||
| .cred-line .dates { color: #555; font-size: .6rem; } |  | ||||||
| /* Permission grid */ |  | ||||||
| .permission-grid { display: grid; grid-template-columns: minmax(220px,2fr) minmax(160px,3fr) 70px 90px; gap: 2px; margin-top: .5rem; } |  | ||||||
| .permission-grid .perm-grid-head { font-size: .6rem; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; padding: .35rem .4rem; background: #f3f3f3; border: 1px solid #e1e1e1; } |  | ||||||
| .permission-grid .perm-cell { background: #fff; border: 1px solid #eee; padding: .35rem .4rem; font-size: .7rem; display: flex; align-items: center; gap: .4rem; } |  | ||||||
| .permission-grid .perm-name { flex-direction: row; flex-wrap: wrap; } |  | ||||||
| .permission-grid .perm-name { flex-direction: column; align-items: flex-start; gap:2px; } |  | ||||||
| .permission-grid .perm-title-line { font-weight:600; line-height:1.1; } |  | ||||||
| .permission-grid .perm-id-line { font-size:.55rem; line-height:1.1; word-break:break-all; } |  | ||||||
| .permission-grid .center { justify-content: center; } |  | ||||||
| .permission-grid .perm-actions { gap: .25rem; } |  | ||||||
| .permission-grid .perm-actions .icon-btn { font-size: .9rem; } |  | ||||||
| /* Inline edit overlay to avoid layout shift */ |  | ||||||
| .perm-actions-inner { position: relative; display:flex; width:100%; justify-content:center; } |  | ||||||
| .perm-actions-inner .inline-id-form.overlay { position:absolute; inset:0; display:none; align-items:center; justify-content:center; gap:.25rem; background:rgba(255,255,255,.9); backdrop-filter:blur(2px); padding:0 .15rem; } |  | ||||||
| .perm-actions-inner.editing .inline-id-form.overlay { display:inline-flex; } |  | ||||||
| .perm-actions-inner.editing .actions-view { visibility:hidden; } |  | ||||||
| /* Inline forms */ |  | ||||||
| .inline-form, .inline-id-form { display:inline-flex; gap:.25rem; align-items:center; } |  | ||||||
| .inline-form input, .inline-id-form input { padding:.25rem .4rem; font-size:.6rem; border:1px solid #ccc; border-radius:4px; } |  | ||||||
| .inline-form button, .inline-id-form button { font-size:.6rem; padding:.3rem .5rem; } |  | ||||||
| .inline-id-form .id-input { width:120px; } |  | ||||||
| /* Modal */ |  | ||||||
| .modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.4); display:flex; justify-content:center; align-items:flex-start; padding-top:8vh; z-index:200; } |  | ||||||
| .modal { background:#fff; border-radius:10px; padding:1rem 1.1rem; width: min(420px, 90%); box-shadow:0 10px 30px rgba(0,0,0,.25); animation:pop .18s ease; } |  | ||||||
| @keyframes pop { from { transform:translateY(10px); opacity:0 } to { transform:translateY(0); opacity:1 } } |  | ||||||
| .modal-title { margin:0 0 .65rem; font-size:1rem; } |  | ||||||
| .modal-form { display:flex; flex-direction:column; gap:.65rem; } |  | ||||||
| .modal-form label { display:flex; flex-direction:column; font-size:.65rem; gap:.25rem; font-weight:600; } |  | ||||||
| .modal-form input, .modal-form textarea { border:1px solid #ccc; border-radius:6px; padding:.45rem .55rem; font-size:.7rem; font-weight:400; font-family:inherit; } |  | ||||||
| .modal-form textarea { resize:vertical; } |  | ||||||
| .modal-actions { display:flex; gap:.5rem; justify-content:flex-end; margin-top:.25rem; } |  | ||||||
| .modal-actions button { font-size:.65rem; } |  | ||||||
| /* Org pill editing */ |  | ||||||
| .perm-orgs { flex-wrap: wrap; gap: .25rem; } |  | ||||||
| .perm-orgs .org-pill { background:#eef4ff; border:1px solid #d0dcf0; padding:2px 6px; border-radius:999px; font-size:.55rem; display:inline-flex; align-items:center; gap:4px; } |  | ||||||
| .perm-orgs .org-pill .pill-x { background:none; border:none; cursor:pointer; font-size:.7rem; line-height:1; padding:0; margin:0; color:#555; } |  | ||||||
| .perm-orgs .org-pill .pill-x:hover { color:#c00; } |  | ||||||
| .add-org-btn { background:none; border:none; cursor:pointer; font-size:.7rem; padding:0 2px; line-height:1; opacity:.55; display:inline; } |  | ||||||
| .add-org-btn:hover, .add-org-btn:focus { opacity:1; } |  | ||||||
| .add-org-btn:focus-visible { outline:2px solid #555; outline-offset:2px; } |  | ||||||
| .org-add-wrapper { position:relative; display:inline-block; } |  | ||||||
| .org-add-menu { position:absolute; top:100%; left:0; z-index:20; margin-top:4px; min-width:160px; background:#fff; border:1px solid #e2e6ea; border-radius:6px; padding:.3rem .35rem; box-shadow:0 4px 10px rgba(0,0,0,.08); display:flex; flex-direction:column; gap:.25rem; font-size:.6rem; } |  | ||||||
| .org-add-menu:before { content:""; position:absolute; top:-5px; left:10px; width:8px; height:8px; background:#fff; border-left:1px solid #e2e6ea; border-top:1px solid #e2e6ea; transform:rotate(45deg); } |  | ||||||
| .org-add-list { display:flex; flex-direction:column; gap:0; max-height:180px; overflow-y:auto; scrollbar-width:thin; } |  | ||||||
| .org-add-item { background:transparent; border:none; padding:.25rem .4rem; font-size:.6rem; border-radius:4px; cursor:pointer; line-height:1.1; text-align:left; width:100%; color:#222; } |  | ||||||
| .org-add-item:hover, .org-add-item:focus { background:#f2f5f9; } |  | ||||||
| .org-add-item:active { background:#e6ebf0; } |  | ||||||
| .org-add-footer { margin-top:.25rem; display:flex; justify-content:flex-end; } |  | ||||||
| .org-add-cancel { background:transparent; border:none; font-size:.55rem; padding:.15rem .35rem; cursor:pointer; color:#666; border-radius:4px; } |  | ||||||
| .org-add-cancel:hover, .org-add-cancel:focus { background:#f2f5f9; color:#222; } |  | ||||||
| .org-add-cancel:active { background:#e6ebf0; } |  | ||||||
| </style> | </style> | ||||||
|   | |||||||
							
								
								
									
										128
									
								
								frontend/src/admin/AdminDialogs.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								frontend/src/admin/AdminDialogs.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,128 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, watch, nextTick } from 'vue' | ||||||
|  | import Modal from '@/components/Modal.vue' | ||||||
|  | import NameEditForm from '@/components/NameEditForm.vue' | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   dialog: Object, | ||||||
|  |   PERMISSION_ID_PATTERN: String | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['submitDialog', 'closeDialog']) | ||||||
|  |  | ||||||
|  | const nameInput = ref(null) | ||||||
|  | const displayNameInput = ref(null) | ||||||
|  |  | ||||||
|  | const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name']) | ||||||
|  |  | ||||||
|  | watch(() => props.dialog.type, (newType) => { | ||||||
|  |   if (newType === 'org-create') { | ||||||
|  |     nextTick(() => { | ||||||
|  |       nameInput.value?.focus() | ||||||
|  |     }) | ||||||
|  |   } else if (newType === 'perm-display' || newType === 'perm-create') { | ||||||
|  |     nextTick(() => { | ||||||
|  |       displayNameInput.value?.focus() | ||||||
|  |       if (newType === 'perm-display') { | ||||||
|  |         displayNameInput.value?.select() | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  | }) | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <Modal v-if="dialog.type" @close="$emit('closeDialog')"> | ||||||
|  |       <h3 class="modal-title"> | ||||||
|  |         <template v-if="dialog.type==='org-create'">Create Organization</template> | ||||||
|  |         <template v-else-if="dialog.type==='org-update'">Rename Organization</template> | ||||||
|  |         <template v-else-if="dialog.type==='role-create'">Create Role</template> | ||||||
|  |         <template v-else-if="dialog.type==='role-update'">Edit Role</template> | ||||||
|  |         <template v-else-if="dialog.type==='user-create'">Add User To Role</template> | ||||||
|  |         <template v-else-if="dialog.type==='user-update-name'">Edit User Name</template> | ||||||
|  |         <template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission Display' }}</template> | ||||||
|  |         <template v-else-if="dialog.type==='confirm'">Confirm</template> | ||||||
|  |       </h3> | ||||||
|  |       <form @submit.prevent="$emit('submitDialog')" class="modal-form"> | ||||||
|  |         <template v-if="dialog.type==='org-create'"> | ||||||
|  |           <label>Name | ||||||
|  |             <input ref="nameInput" v-model="dialog.data.name" required /> | ||||||
|  |           </label> | ||||||
|  |         </template> | ||||||
|  |         <template v-else-if="dialog.type==='org-update'"> | ||||||
|  |           <NameEditForm | ||||||
|  |             label="Organization Name" | ||||||
|  |             v-model="dialog.data.name" | ||||||
|  |             :busy="dialog.busy" | ||||||
|  |             :error="dialog.error" | ||||||
|  |             @cancel="$emit('closeDialog')" | ||||||
|  |           /> | ||||||
|  |         </template> | ||||||
|  |         <template v-else-if="dialog.type==='role-create'"> | ||||||
|  |           <label>Role Name | ||||||
|  |             <input v-model="dialog.data.name" placeholder="Role name" required /> | ||||||
|  |           </label> | ||||||
|  |         </template> | ||||||
|  |         <template v-else-if="dialog.type==='role-update'"> | ||||||
|  |           <NameEditForm | ||||||
|  |             label="Role Name" | ||||||
|  |             v-model="dialog.data.name" | ||||||
|  |             :busy="dialog.busy" | ||||||
|  |             :error="dialog.error" | ||||||
|  |             @cancel="$emit('closeDialog')" | ||||||
|  |           /> | ||||||
|  |         </template> | ||||||
|  |         <template v-else-if="dialog.type==='user-create'"> | ||||||
|  |           <p class="small muted">Role: {{ dialog.data.role.display_name }}</p> | ||||||
|  |           <label>Display Name | ||||||
|  |             <input v-model="dialog.data.name" placeholder="User display name" required /> | ||||||
|  |           </label> | ||||||
|  |         </template> | ||||||
|  |         <template v-else-if="dialog.type==='user-update-name'"> | ||||||
|  |           <NameEditForm | ||||||
|  |             label="Display Name" | ||||||
|  |             v-model="dialog.data.name" | ||||||
|  |             :busy="dialog.busy" | ||||||
|  |             :error="dialog.error" | ||||||
|  |             @cancel="$emit('closeDialog')" | ||||||
|  |           /> | ||||||
|  |         </template> | ||||||
|  |         <template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'"> | ||||||
|  |           <label>Display Name | ||||||
|  |             <input ref="displayNameInput" v-model="dialog.data.display_name" required /> | ||||||
|  |           </label> | ||||||
|  |           <label>Permission ID | ||||||
|  |             <input v-model="dialog.data.id" :placeholder="dialog.type === 'perm-create' ? 'yourapp:login' : dialog.data.permission.id" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" /> | ||||||
|  |           </label> | ||||||
|  |           <p class="small muted">The permission ID is used for permission checks in the application. Changing it may break deployed applications that reference this permission.</p> | ||||||
|  |         </template> | ||||||
|  |         <template v-else-if="dialog.type==='confirm'"> | ||||||
|  |           <p>{{ dialog.data.message }}</p> | ||||||
|  |         </template> | ||||||
|  |         <div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div> | ||||||
|  |         <div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions"> | ||||||
|  |           <button | ||||||
|  |             type="button" | ||||||
|  |             class="btn-secondary" | ||||||
|  |             @click="$emit('closeDialog')" | ||||||
|  |             :disabled="dialog.busy" | ||||||
|  |           > | ||||||
|  |             Cancel | ||||||
|  |           </button> | ||||||
|  |           <button | ||||||
|  |             type="submit" | ||||||
|  |             class="btn-primary" | ||||||
|  |             :disabled="dialog.busy" | ||||||
|  |           > | ||||||
|  |             {{ dialog.type==='confirm' ? 'OK' : 'Save' }} | ||||||
|  |           </button> | ||||||
|  |         </div> | ||||||
|  |       </form> | ||||||
|  |   </Modal> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .error { color: var(--color-danger-text); } | ||||||
|  | .small { font-size: 0.9rem; } | ||||||
|  | .muted { color: var(--color-text-muted); } | ||||||
|  | </style> | ||||||
							
								
								
									
										157
									
								
								frontend/src/admin/AdminOrgDetail.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								frontend/src/admin/AdminOrgDetail.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,157 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed } from 'vue' | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   selectedOrg: Object, | ||||||
|  |   permissions: Array | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'onRoleDragOver', 'onRoleDrop', 'onUserDragStart']) | ||||||
|  |  | ||||||
|  | const sortedRoles = computed(() => { | ||||||
|  |   return [...props.selectedOrg.roles].sort((a, b) => { | ||||||
|  |     const nameA = a.display_name.toLowerCase() | ||||||
|  |     const nameB = b.display_name.toLowerCase() | ||||||
|  |     if (nameA !== nameB) { | ||||||
|  |       return nameA.localeCompare(nameB) | ||||||
|  |     } | ||||||
|  |     return a.uuid.localeCompare(b.uuid) | ||||||
|  |   }) | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | function permissionDisplayName(id) { | ||||||
|  |   return props.permissions.find(p => p.id === id)?.display_name || id | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function toggleRolePermission(role, pid, checked) { | ||||||
|  |   emit('toggleRolePermission', role, pid, checked) | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <h2 class="org-title" :title="selectedOrg.uuid"> | ||||||
|  |     <span class="org-name">{{ selectedOrg.display_name }}</span> | ||||||
|  |     <button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button> | ||||||
|  |   </h2> | ||||||
|  |  | ||||||
|  |     <div class="matrix-wrapper"> | ||||||
|  |       <div class="matrix-scroll"> | ||||||
|  |         <div | ||||||
|  |           class="perm-matrix-grid" | ||||||
|  |           :style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + sortedRoles.map(()=> '2.2rem').join(' ') + ' 2.2rem' }" | ||||||
|  |         > | ||||||
|  |           <div class="grid-head perm-head">Permission</div> | ||||||
|  |           <div | ||||||
|  |             v-for="r in sortedRoles" | ||||||
|  |             :key="'head-' + r.uuid" | ||||||
|  |             class="grid-head role-head" | ||||||
|  |             :title="r.display_name" | ||||||
|  |           > | ||||||
|  |             <span>{{ r.display_name }}</span> | ||||||
|  |           </div> | ||||||
|  |           <div class="grid-head role-head add-role-head" title="Add role" @click="$emit('createRole', selectedOrg)" role="button">➕</div> | ||||||
|  |  | ||||||
|  |           <template v-for="pid in selectedOrg.permissions" :key="pid"> | ||||||
|  |             <div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div> | ||||||
|  |             <div | ||||||
|  |               v-for="r in sortedRoles" | ||||||
|  |               :key="r.uuid + '-' + pid" | ||||||
|  |               class="matrix-cell" | ||||||
|  |             > | ||||||
|  |               <input | ||||||
|  |                 type="checkbox" | ||||||
|  |                 :checked="r.permissions.includes(pid)" | ||||||
|  |                 @change="e => toggleRolePermission(r, pid, e.target.checked)" | ||||||
|  |               /> | ||||||
|  |             </div> | ||||||
|  |             <div class="matrix-cell add-role-cell" /> | ||||||
|  |           </template> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <p class="matrix-hint muted">Toggle which permissions each role grants.</p> | ||||||
|  |     </div> | ||||||
|  |     <div class="roles-grid"> | ||||||
|  |       <div | ||||||
|  |         v-for="r in sortedRoles" | ||||||
|  |         :key="r.uuid" | ||||||
|  |         class="role-column" | ||||||
|  |         @dragover="$emit('onRoleDragOver', $event)" | ||||||
|  |         @drop="e => $emit('onRoleDrop', e, selectedOrg, r)" | ||||||
|  |       > | ||||||
|  |         <div class="role-header"> | ||||||
|  |           <strong class="role-name" :title="r.uuid"> | ||||||
|  |             <span>{{ r.display_name }}</span> | ||||||
|  |             <button @click="$emit('updateRole', r)" class="icon-btn" aria-label="Edit role" title="Edit role">✏️</button> | ||||||
|  |           </strong> | ||||||
|  |           <div class="role-actions"> | ||||||
|  |             <button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user">➕</button> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |         <template v-if="r.users.length > 0"> | ||||||
|  |           <ul class="user-list"> | ||||||
|  |             <li | ||||||
|  |               v-for="u in r.users.slice().sort((a, b) => { | ||||||
|  |                 const nameA = a.display_name.toLowerCase() | ||||||
|  |                 const nameB = b.display_name.toLowerCase() | ||||||
|  |                 if (nameA !== nameB) { | ||||||
|  |                   return nameA.localeCompare(nameB) | ||||||
|  |                 } | ||||||
|  |                 return a.uuid.localeCompare(b.uuid) | ||||||
|  |               })" | ||||||
|  |               :key="u.uuid" | ||||||
|  |               class="user-chip" | ||||||
|  |               draggable="true" | ||||||
|  |               @dragstart="e => $emit('onUserDragStart', e, u, selectedOrg.uuid)" | ||||||
|  |               @click="$emit('openUser', u)" | ||||||
|  |               :title="u.uuid" | ||||||
|  |             > | ||||||
|  |               <span class="name">{{ u.display_name }}</span> | ||||||
|  |               <span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span> | ||||||
|  |             </li> | ||||||
|  |           </ul> | ||||||
|  |         </template> | ||||||
|  |         <div v-else class="empty-role"> | ||||||
|  |           <p class="empty-text muted">No members</p> | ||||||
|  |           <button @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete empty role" title="Delete role">❌</button> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .card.surface { padding: var(--space-lg); } | ||||||
|  | .org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); } | ||||||
|  | .org-name { font-size: 1.5rem; font-weight: 600; color: var(--color-heading); } | ||||||
|  | .icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; } | ||||||
|  | .icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); } | ||||||
|  | .matrix-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); } | ||||||
|  | .matrix-scroll { overflow-x: auto; } | ||||||
|  | .matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); } | ||||||
|  | .perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; } | ||||||
|  | .perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; } | ||||||
|  | .perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; } | ||||||
|  | .perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; } | ||||||
|  | .perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; } | ||||||
|  | .perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; } | ||||||
|  | .perm-matrix-grid .add-role-head { cursor: pointer; } | ||||||
|  | .perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; } | ||||||
|  | .roles-grid { display: flex; gap: var(--space-lg); margin-top: var(--space-lg); } | ||||||
|  | .role-column { flex: 1; min-width: 200px; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--space-md); } | ||||||
|  | .role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); } | ||||||
|  | .role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); } | ||||||
|  | .role-actions { display: flex; gap: var(--space-xs); } | ||||||
|  | .plus-btn { background: var(--color-accent-soft); color: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: 0.25rem 0.45rem; font-size: 1.1rem; cursor: pointer; } | ||||||
|  | .plus-btn:hover { background: rgba(37, 99, 235, 0.18); } | ||||||
|  | .user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); } | ||||||
|  | .user-chip { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; } | ||||||
|  | .user-chip .meta { font-size: 0.7rem; color: var(--color-text-muted); } | ||||||
|  | .empty-role { border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-sm); display: flex; flex-direction: column; gap: var(--space-xs); align-items: flex-start; } | ||||||
|  | .empty-text { margin: 0; } | ||||||
|  | .delete-icon { color: var(--color-danger); } | ||||||
|  | .delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); } | ||||||
|  | .muted { color: var(--color-text-muted); } | ||||||
|  |  | ||||||
|  | @media (max-width: 720px) { | ||||||
|  |   .roles-grid { flex-direction: column; } | ||||||
|  | } | ||||||
|  | </style> | ||||||
							
								
								
									
										165
									
								
								frontend/src/admin/AdminOverview.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								frontend/src/admin/AdminOverview.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,165 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed } from 'vue' | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   info: Object, | ||||||
|  |   orgs: Array, | ||||||
|  |   permissions: Array, | ||||||
|  |   permissionSummary: Object | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay']) | ||||||
|  |  | ||||||
|  | const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> { | ||||||
|  |   const nameCompare = a.display_name.localeCompare(b.display_name) | ||||||
|  |   return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid) | ||||||
|  | })) | ||||||
|  | const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.id.localeCompare(b.id))) | ||||||
|  |  | ||||||
|  | function permissionDisplayName(id) { | ||||||
|  |   return props.permissions.find(p => p.id === id)?.display_name || id | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function getRoleNames(org) { | ||||||
|  |   return org.roles | ||||||
|  |     .slice() | ||||||
|  |     .sort((a, b) => a.display_name.localeCompare(b.display_name)) | ||||||
|  |     .map(r => r.display_name) | ||||||
|  |     .join(', ') | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="permissions-section"> | ||||||
|  |     <h2>{{ info.is_global_admin ? 'Organizations' : 'Your Organizations' }}</h2> | ||||||
|  |     <div class="actions"> | ||||||
|  |       <button v-if="info.is_global_admin" @click="$emit('createOrg')">+ Create Org</button> | ||||||
|  |     </div> | ||||||
|  |     <table class="org-table"> | ||||||
|  |       <thead> | ||||||
|  |         <tr> | ||||||
|  |           <th>Name</th> | ||||||
|  |           <th>Roles</th> | ||||||
|  |           <th>Members</th> | ||||||
|  |           <th v-if="info.is_global_admin">Actions</th> | ||||||
|  |         </tr> | ||||||
|  |       </thead> | ||||||
|  |       <tbody> | ||||||
|  |         <tr v-for="o in sortedOrgs" :key="o.uuid"> | ||||||
|  |           <td> | ||||||
|  |             <a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.display_name }}</a> | ||||||
|  |             <button v-if="info.is_global_admin || info.is_org_admin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization">✏️</button> | ||||||
|  |           </td> | ||||||
|  |           <td class="role-names">{{ getRoleNames(o) }}</td> | ||||||
|  |           <td class="center">{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td> | ||||||
|  |           <td v-if="info.is_global_admin" class="center"> | ||||||
|  |             <button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization">❌</button> | ||||||
|  |           </td> | ||||||
|  |         </tr> | ||||||
|  |       </tbody> | ||||||
|  |     </table> | ||||||
|  |   </div> | ||||||
|  |  | ||||||
|  |   <div v-if="info.is_global_admin" class="permissions-section"> | ||||||
|  |     <h2>Permissions</h2> | ||||||
|  |     <div class="matrix-wrapper"> | ||||||
|  |       <div class="matrix-scroll"> | ||||||
|  |         <div | ||||||
|  |           class="perm-matrix-grid" | ||||||
|  |           :style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + sortedOrgs.map(()=> '2.2rem').join(' ') }" | ||||||
|  |         > | ||||||
|  |           <div class="grid-head perm-head">Permission</div> | ||||||
|  |           <div | ||||||
|  |             v-for="o in sortedOrgs" | ||||||
|  |             :key="'head-' + o.uuid" | ||||||
|  |             class="grid-head org-head" | ||||||
|  |             :title="o.display_name" | ||||||
|  |           > | ||||||
|  |             <span>{{ o.display_name }}</span> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           <template v-for="p in sortedPermissions" :key="p.id"> | ||||||
|  |             <div class="perm-name" :title="p.id"> | ||||||
|  |               <span class="display-text">{{ p.display_name }}</span> | ||||||
|  |             </div> | ||||||
|  |             <div | ||||||
|  |               v-for="o in sortedOrgs" | ||||||
|  |               :key="o.uuid + '-' + p.id" | ||||||
|  |               class="matrix-cell" | ||||||
|  |             > | ||||||
|  |               <input | ||||||
|  |                 type="checkbox" | ||||||
|  |                 :checked="o.permissions.includes(p.id)" | ||||||
|  |                 @change="e => $emit('toggleOrgPermission', o, p.id, e.target.checked)" | ||||||
|  |               /> | ||||||
|  |             </div> | ||||||
|  |           </template> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <p class="matrix-hint muted">Toggle which permissions each organization can grant to its members.</p> | ||||||
|  |     </div> | ||||||
|  |     <div class="actions"> | ||||||
|  |       <button v-if="info.is_global_admin" @click="$emit('openDialog', 'perm-create', { display_name: '', id: '' })">+ Create Permission</button> | ||||||
|  |     </div> | ||||||
|  |     <table class="org-table"> | ||||||
|  |         <thead> | ||||||
|  |           <tr> | ||||||
|  |             <th scope="col">Permission</th> | ||||||
|  |             <th scope="col" class="center">Members</th> | ||||||
|  |             <th scope="col" class="center">Actions</th> | ||||||
|  |           </tr> | ||||||
|  |         </thead> | ||||||
|  |         <tbody> | ||||||
|  |           <tr v-for="p in sortedPermissions" :key="p.id"> | ||||||
|  |             <td class="perm-name-cell"> | ||||||
|  |               <div class="perm-title"> | ||||||
|  |                 <span class="display-text">{{ p.display_name }}</span> | ||||||
|  |                 <button @click="$emit('renamePermissionDisplay', p)" class="icon-btn edit-display-btn" aria-label="Edit display name" title="Edit display name">✏️</button> | ||||||
|  |               </div> | ||||||
|  |               <div class="perm-id-info"> | ||||||
|  |                 <span class="id-text">{{ p.id }}</span> | ||||||
|  |               </div> | ||||||
|  |             </td> | ||||||
|  |             <td class="perm-members center">{{ permissionSummary[p.id]?.userCount || 0 }}</td> | ||||||
|  |             <td class="perm-actions center"> | ||||||
|  |               <button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission">❌</button> | ||||||
|  |             </td> | ||||||
|  |           </tr> | ||||||
|  |         </tbody> | ||||||
|  |       </table> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .permissions-section { margin-bottom: var(--space-xl); } | ||||||
|  | .permissions-section h2 { margin-bottom: var(--space-md); } | ||||||
|  | .actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; } | ||||||
|  | .actions button { width: auto; } | ||||||
|  | .org-table a { text-decoration: none; color: var(--color-link); } | ||||||
|  | .org-table a:hover { text-decoration: underline; } | ||||||
|  | .org-table .center { width: 6rem; min-width: 6rem; } | ||||||
|  | .org-table .role-names { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | ||||||
|  | .perm-name-cell { display: flex; flex-direction: column; gap: 0.3rem; } | ||||||
|  | .perm-title { font-weight: 600; color: var(--color-heading); } | ||||||
|  | .perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); } | ||||||
|  | .icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; } | ||||||
|  | .icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); } | ||||||
|  | .delete-icon { color: var(--color-danger); } | ||||||
|  | .delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); } | ||||||
|  | .matrix-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); } | ||||||
|  | .matrix-scroll { overflow-x: auto; } | ||||||
|  | .matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); } | ||||||
|  | .perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; } | ||||||
|  | .perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; } | ||||||
|  | .perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; } | ||||||
|  | .perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; } | ||||||
|  | .perm-matrix-grid .org-head { display: flex; align-items: flex-end; justify-content: center; } | ||||||
|  | .perm-matrix-grid .org-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; } | ||||||
|  | .perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; } | ||||||
|  | .display-text { margin-right: var(--space-xs); } | ||||||
|  | .edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; } | ||||||
|  | .edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); } | ||||||
|  | .perm-actions { text-align: center; } | ||||||
|  | .center { text-align: center; } | ||||||
|  | .muted { color: var(--color-text-muted); } | ||||||
|  | </style> | ||||||
							
								
								
									
										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 class="credential-list"> | ||||||
|     <div v-if="loading"><p>Loading credentials...</p></div> |     <div v-if="loading"><p>Loading credentials...</p></div> | ||||||
|     <div v-else-if="!credentials?.length"><p>No passkeys found.</p></div> |     <div v-else-if="!credentials?.length"><p>No passkeys found.</p></div> | ||||||
|     <div v-else> |     <template v-else> | ||||||
|       <div |       <div | ||||||
|         v-for="credential in credentials" |         v-for="credential in credentials" | ||||||
|         :key="credential.credential_uuid" |         :key="credential.credential_uuid" | ||||||
| @@ -39,7 +39,7 @@ | |||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </template> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| @@ -69,16 +69,119 @@ const getCredentialAuthIcon = (credential) => { | |||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <style scoped> | <style scoped> | ||||||
| .credential-list { display: flex; flex-direction: column; gap: .75rem; margin-top: .5rem; } | .credential-list { | ||||||
| .credential-item { border: 1px solid #ddd; border-radius: 8px; padding: .5rem .75rem; background: #fff; } |   width: 100%; | ||||||
| .credential-header { display: flex; align-items: center; gap: 1rem; } |   margin-top: var(--space-sm); | ||||||
| .credential-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; } |   display: grid; | ||||||
| .auth-icon { border-radius: 6px; } |   grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); | ||||||
| .credential-info { flex: 1 1 auto; } |   gap: 1rem 1.25rem; | ||||||
| .credential-info h4 { margin: 0; font-size: .9rem; } |   align-items: stretch; | ||||||
| .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; } | .credential-item { | ||||||
| .btn-delete-credential { background: none; border: none; cursor: pointer; font-size: .9rem; } |   border: 1px solid var(--color-border); | ||||||
| .btn-delete-credential:disabled { opacity: .3; cursor: not-allowed; } |   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> | </style> | ||||||
|   | |||||||
| @@ -1,39 +1,48 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="container"> |   <section class="view-root view-device-link"> | ||||||
|     <div class="view active"> |     <div class="view-content view-content--narrow"> | ||||||
|       <h1>📱 Add Another Device</h1> |       <header class="view-header"> | ||||||
|       <div class="device-link-section"> |         <h1>📱 Add Another Device</h1> | ||||||
|         <div class="qr-container"> |         <p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p> | ||||||
|           <a :href="url" id="deviceLinkText" @click="copyLink"> |       </header> | ||||||
|             <canvas id="qrCode" class="qr-code"></canvas> |       <section class="section-block"> | ||||||
|             <p v-if="url"> |         <div class="section-body"> | ||||||
|               {{ url.replace(/^[^:]+:\/\//, '') }} |           <div class="device-link-section"> | ||||||
|             </p> |             <div class="qr-container"> | ||||||
|             <p v-else> |               <a :href="url" class="qr-link" @click="copyLink"> | ||||||
|               <em>Generating link...</em> |                 <canvas ref="qrCanvas" class="qr-code"></canvas> | ||||||
|             </p> |                 <p v-if="url"> | ||||||
|           </a> |                   {{ url.replace(/^[^:]+:\/\//, '') }} | ||||||
|           <p> |                 </p> | ||||||
|             <strong>Scan and visit the URL on another device.</strong><br> |                 <p v-else> | ||||||
|             <small>⚠️ Expires in 24 hours and can only be used once.</small> |                   <em>Generating link...</em> | ||||||
|           </p> |                 </p> | ||||||
|  |               </a> | ||||||
|  |               <p> | ||||||
|  |                 <strong>Scan and visit the URL on another device.</strong><br> | ||||||
|  |                 <small>⚠️ Expires in 24 hours and can only be used once.</small> | ||||||
|  |               </p> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |           <div class="button-row"> | ||||||
|  |             <button @click="authStore.currentView = 'profile'" class="btn-secondary"> | ||||||
|  |               Back to Profile | ||||||
|  |             </button> | ||||||
|  |           </div> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </section> | ||||||
|  |  | ||||||
|       <button @click="authStore.currentView = 'profile'" class="btn-secondary"> |  | ||||||
|         Back to Profile |  | ||||||
|       </button> |  | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </section> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup> | <script setup> | ||||||
| import { ref, onMounted } from 'vue' | import { ref, onMounted, nextTick } from 'vue' | ||||||
| import { useAuthStore } from '@/stores/auth' | import { useAuthStore } from '@/stores/auth' | ||||||
| import QRCode from 'qrcode/lib/browser' | import QRCode from 'qrcode/lib/browser' | ||||||
|  |  | ||||||
| const authStore = useAuthStore() | const authStore = useAuthStore() | ||||||
| const url = ref(null) | const url = ref(null) | ||||||
|  | const qrCanvas = ref(null) | ||||||
|  |  | ||||||
| const copyLink = async (event) => { | const copyLink = async (event) => { | ||||||
|   event.preventDefault() |   event.preventDefault() | ||||||
| @@ -44,24 +53,56 @@ 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 () => { | onMounted(async () => { | ||||||
|   try { |   try { | ||||||
|   const response = await fetch('/auth/api/create-link', { method: 'POST' }) |     const response = await fetch('/auth/api/create-link', { method: 'POST' }) | ||||||
|     const result = await response.json() |     const result = await response.json() | ||||||
|     if (result.detail) throw new Error(result.detail) |     if (result.detail) throw new Error(result.detail) | ||||||
|  |  | ||||||
|     url.value = result.url |     url.value = result.url | ||||||
|  |     await drawQr() | ||||||
|     // Generate QR code |  | ||||||
|     const qrCodeElement = document.getElementById('qrCode') |  | ||||||
|     if (qrCodeElement) { |  | ||||||
|       QRCode.toCanvas(qrCodeElement, url.value, {scale: 8 }, error => { |  | ||||||
|         if (error) console.error('Failed to generate QR code:', error) |  | ||||||
|       }) |  | ||||||
|     } |  | ||||||
|   } catch (error) { |   } catch (error) { | ||||||
|     authStore.showMessage(`Failed to create device link: ${error.message}`, 'error') |     authStore.showMessage(`Failed to create device link: ${error.message}`, 'error') | ||||||
|     authStore.currentView = 'profile' |     authStore.currentView = 'profile' | ||||||
|   } |   } | ||||||
| }) | }) | ||||||
|  |  | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .view-content--narrow { | ||||||
|  |   max-width: 540px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .view-lede { | ||||||
|  |   margin: 0; | ||||||
|  |   color: var(--color-text-muted); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .qr-link { | ||||||
|  |   text-decoration: none; | ||||||
|  |   color: var(--color-text); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .button-row { | ||||||
|  |   justify-content: flex-start; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @media (max-width: 720px) { | ||||||
|  |   .button-row { | ||||||
|  |     flex-direction: column; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .button-row button { | ||||||
|  |     width: 100%; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </style> | ||||||
|   | |||||||
| @@ -1,16 +1,23 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="container"> |   <div class="dialog-backdrop"> | ||||||
|     <div class="view active"> |     <div class="dialog-container"> | ||||||
|   <h1>🔐 {{ (authStore.settings?.rp_name || 'Passkey') + ' Login' }}</h1> |       <div class="dialog-content dialog-content--narrow"> | ||||||
|       <form @submit.prevent="handleLogin"> |         <header class="view-header"> | ||||||
|         <button |           <h1>🔐 {{ (authStore.settings?.rp_name || location.origin)}}</h1> | ||||||
|           type="submit" |           <p class="view-lede">User authentication is required for access.</p> | ||||||
|           class="btn-primary" |         </header> | ||||||
|           :disabled="authStore.isLoading" |         <section class="section-block"> | ||||||
|         > |           <form class="section-body" @submit.prevent="handleLogin"> | ||||||
|           {{ authStore.isLoading ? 'Authenticating...' : 'Login with Your Device' }} |             <button | ||||||
|         </button> |               type="submit" | ||||||
|       </form> |               class="btn-primary" | ||||||
|  |               :disabled="authStore.isLoading" | ||||||
|  |             > | ||||||
|  |               {{ authStore.isLoading ? 'Authenticating...' : 'Login with Your Device' }} | ||||||
|  |             </button> | ||||||
|  |           </form> | ||||||
|  |         </section> | ||||||
|  |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
| @@ -22,12 +29,10 @@ const authStore = useAuthStore() | |||||||
|  |  | ||||||
| const handleLogin = async () => { | const handleLogin = async () => { | ||||||
|   try { |   try { | ||||||
|     console.log('Login button clicked') |  | ||||||
|     authStore.showMessage('Starting authentication...', 'info') |     authStore.showMessage('Starting authentication...', 'info') | ||||||
|     await authStore.authenticate() |     await authStore.authenticate() | ||||||
|     authStore.showMessage('Authentication successful!', 'success', 2000) |     authStore.showMessage('Authentication successful!', 'success', 2000) | ||||||
|     if (authStore.restrictedMode) { |     if (authStore.restrictedMode) { | ||||||
|       // Restricted mode: reload so the app re-mounts and selectView() applies (will become permission denied) |  | ||||||
|       location.reload() |       location.reload() | ||||||
|     } else if (location.pathname === '/auth/') { |     } else if (location.pathname === '/auth/') { | ||||||
|       authStore.currentView = 'profile' |       authStore.currentView = 'profile' | ||||||
| @@ -39,3 +44,20 @@ const handleLogin = async () => { | |||||||
|   } |   } | ||||||
| } | } | ||||||
| </script> | </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,16 +1,25 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="container"> |   <div class="dialog-backdrop"> | ||||||
|     <div class="view active"> |     <div class="dialog-container"> | ||||||
|       <h1>🚫 Forbidden</h1> |       <div class="dialog-content dialog-content--wide"> | ||||||
|       <div v-if="authStore.userInfo?.authenticated" class="user-header"> |         <header class="view-header"> | ||||||
|         <span class="user-emoji" aria-hidden="true">{{ userEmoji }}</span> |           <h1>🚫 Forbidden</h1> | ||||||
|         <span class="user-name">{{ displayName }}</span> |         </header> | ||||||
|       </div> |         <section class="section-block"> | ||||||
|       <p>You lack the permissions required for this page.</p> |           <div class="section-body"> | ||||||
|       <div class="actions"> |             <div v-if="authStore.userInfo?.authenticated" class="user-header"> | ||||||
|         <button class="btn-secondary" @click="back">Back</button> |               <span class="user-emoji" aria-hidden="true">{{ userEmoji }}</span> | ||||||
|         <button class="btn-primary" @click="goAuth">Account</button> |               <span class="user-name">{{ displayName }}</span> | ||||||
|         <button class="btn-danger" @click="logout">Logout</button> |             </div> | ||||||
|  |             <p>You lack the permissions required for this page.</p> | ||||||
|  |             <div class="button-row"> | ||||||
|  |               <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> | ||||||
|  |             <p class="hint">If you believe this is an error, contact your administrator.</p> | ||||||
|  |           </div> | ||||||
|  |         </section> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| @@ -35,9 +44,51 @@ async function logout() { | |||||||
| } | } | ||||||
| </script> | </script> | ||||||
| <style scoped> | <style scoped> | ||||||
| .user-header { display:flex; align-items:center; gap:.5rem; font-size:1.1rem; margin-bottom:.75rem; } | .view-lede { | ||||||
| .user-emoji { font-size:1.5rem; line-height:1; } |   margin: 0; | ||||||
| .user-name { font-weight:600; } |   color: var(--color-text-muted); | ||||||
| .actions { margin-top:1.5rem; display:flex; gap:.5rem; flex-wrap:nowrap; } | } | ||||||
| .hint { font-size:.9rem; opacity:.85; } |  | ||||||
|  | .user-header { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   gap: 0.5rem; | ||||||
|  |   font-size: 1.1rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .user-emoji { | ||||||
|  |   font-size: 1.5rem; | ||||||
|  |   line-height: 1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .user-name { | ||||||
|  |   font-weight: 600; | ||||||
|  |   color: var(--color-heading); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .button-row { | ||||||
|  |   width: 100%; | ||||||
|  |   justify-content: stretch; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .button-row button { | ||||||
|  |   flex: 1 1 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .hint { | ||||||
|  |   font-size: 0.9rem; | ||||||
|  |   color: var(--color-text-muted); | ||||||
|  |   margin: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @media (max-width: 720px) { | ||||||
|  |   .button-row { | ||||||
|  |     flex-direction: column; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .button-row button { | ||||||
|  |     width: 100%; | ||||||
|  |     flex: 1 1 auto; | ||||||
|  |   } | ||||||
|  | } | ||||||
| </style> | </style> | ||||||
|   | |||||||
| @@ -1,92 +1,95 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="container"> |   <section class="view-root" data-view="profile"> | ||||||
|     <div class="view active"> |     <div class="view-content"> | ||||||
|   <h1>👋 Welcome! <a v-if="isAdmin" href="/auth/admin/" class="admin-link" title="Admin Console">Admin</a></h1> |       <header class="view-header"> | ||||||
|       <UserBasicInfo |         <h1>👋 Welcome!</h1> | ||||||
|         v-if="authStore.userInfo?.user" |         <Breadcrumbs :entries="[{ label: 'Auth', href: '/auth/' }, ...(isAdmin ? [{ label: 'Admin', href: '/auth/admin/' }] : [])]" /> | ||||||
|         :name="authStore.userInfo.user.user_name" |         <p class="view-lede">Manage your account details and passkeys.</p> | ||||||
|         :visits="authStore.userInfo.user.visits || 0" |       </header> | ||||||
|         :created-at="authStore.userInfo.user.created_at" |  | ||||||
|         :last-seen="authStore.userInfo.user.last_seen" |  | ||||||
|         :loading="authStore.isLoading" |  | ||||||
|   update-endpoint="/auth/api/user/display-name" |  | ||||||
|         @saved="authStore.loadUserInfo()" |  | ||||||
|       /> |  | ||||||
|  |  | ||||||
|       <h2>Your Passkeys</h2> |       <section class="section-block"> | ||||||
|       <div class="credential-list"> |         <UserBasicInfo | ||||||
|         <div v-if="authStore.isLoading"> |           v-if="authStore.userInfo?.user" | ||||||
|           <p>Loading credentials...</p> |           :name="authStore.userInfo.user.user_name" | ||||||
|  |           :visits="authStore.userInfo.user.visits || 0" | ||||||
|  |           :created-at="authStore.userInfo.user.created_at" | ||||||
|  |           :last-seen="authStore.userInfo.user.last_seen" | ||||||
|  |           :loading="authStore.isLoading" | ||||||
|  |           update-endpoint="/auth/api/user/display-name" | ||||||
|  |           @saved="authStore.loadUserInfo()" | ||||||
|  |           @edit-name="openNameDialog" | ||||||
|  |         /> | ||||||
|  |       </section> | ||||||
|  |  | ||||||
|  |       <section class="section-block"> | ||||||
|  |         <div class="section-header"> | ||||||
|  |           <h2>Your Passkeys</h2> | ||||||
|  |           <p class="section-description">Keep at least one trusted passkey so you can always sign in.</p> | ||||||
|         </div> |         </div> | ||||||
|         <div v-else-if="authStore.userInfo?.credentials?.length === 0"> |         <div class="section-body"> | ||||||
|           <p>No passkeys found.</p> |           <CredentialList | ||||||
|         </div> |             :credentials="authStore.userInfo?.credentials || []" | ||||||
|         <div v-else> |             :aaguid-info="authStore.userInfo?.aaguid_info || {}" | ||||||
|           <div |             :loading="authStore.isLoading" | ||||||
|             v-for="credential in authStore.userInfo?.credentials || []" |             allow-delete | ||||||
|             :key="credential.credential_uuid" |             @delete="handleDelete" | ||||||
|             :class="['credential-item', { 'current-session': credential.is_current_session }]" |           /> | ||||||
|           > |           <div class="button-row"> | ||||||
|             <div class="credential-header"> |             <button @click="addNewCredential" class="btn-primary"> | ||||||
|               <div class="credential-icon"> |               Add New Passkey | ||||||
|                 <img |             </button> | ||||||
|                   v-if="getCredentialAuthIcon(credential)" |             <button @click="authStore.currentView = 'device-link'" class="btn-secondary"> | ||||||
|                   :src="getCredentialAuthIcon(credential)" |               Add Another Device | ||||||
|                   :alt="getCredentialAuthName(credential)" |             </button> | ||||||
|                   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> | ||||||
|       </div> |       </section> | ||||||
|  |  | ||||||
|       <div class="button-group" style="display: flex; gap: 10px;"> |       <section class="section-block"> | ||||||
|         <button @click="addNewCredential" class="btn-primary"> |         <div class="button-row"> | ||||||
|           Add New Passkey |           <button @click="logout" class="btn-danger logout-button"> | ||||||
|         </button> |             Logout | ||||||
|         <button @click="authStore.currentView = 'device-link'" class="btn-primary"> |           </button> | ||||||
|           Add Another Device |         </div> | ||||||
|         </button> |       </section> | ||||||
|       </div> |  | ||||||
|       <button @click="logout" class="btn-danger" style="width: 100%;"> |       <!-- Name Edit Dialog --> | ||||||
|         Logout |       <Modal v-if="showNameDialog" @close="showNameDialog = false"> | ||||||
|       </button> |         <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> |     </div> | ||||||
|   </div> |   </section> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup> | <script setup> | ||||||
| import { ref, onMounted, onUnmounted, computed } from 'vue' | import { ref, onMounted, onUnmounted, computed, watch } from 'vue' | ||||||
| import { useAuthStore } from '@/stores/auth' | import Breadcrumbs from '@/components/Breadcrumbs.vue' | ||||||
| import { formatDate } from '@/utils/helpers' | import CredentialList from '@/components/CredentialList.vue' | ||||||
| import passkey from '@/utils/passkey' |  | ||||||
| import UserBasicInfo from '@/components/UserBasicInfo.vue' | import UserBasicInfo from '@/components/UserBasicInfo.vue' | ||||||
|  | import Modal from '@/components/Modal.vue' | ||||||
|  | import NameEditForm from '@/components/NameEditForm.vue' | ||||||
|  | import { useAuthStore } from '@/stores/auth' | ||||||
|  | import passkey from '@/utils/passkey' | ||||||
|  |  | ||||||
| const authStore = useAuthStore() | const authStore = useAuthStore() | ||||||
| const updateInterval = ref(null) | 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(() => { | onMounted(() => { | ||||||
|   updateInterval.value = setInterval(() => { |   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 () => { | const addNewCredential = async () => { | ||||||
|   try { |   try { | ||||||
|     authStore.isLoading = true |     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 |   if (!confirm('Are you sure you want to delete this passkey?')) return | ||||||
|   try { |   try { | ||||||
|     await authStore.deleteCredential(credentialId) |     await authStore.deleteCredential(credentialId) | ||||||
| @@ -146,23 +137,65 @@ const logout = async () => { | |||||||
|   await authStore.logout() |   await authStore.logout() | ||||||
| } | } | ||||||
|  |  | ||||||
|  | const openNameDialog = () => { | ||||||
|  |   newName.value = authStore.userInfo?.user?.user_name || '' | ||||||
|  |   showNameDialog.value = true | ||||||
|  | } | ||||||
|  |  | ||||||
| const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin)) | const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin)) | ||||||
|  |  | ||||||
|  | const saveName = async () => { | ||||||
|  |   const name = newName.value.trim() | ||||||
|  |   if (!name) { | ||||||
|  |     authStore.showMessage('Name cannot be empty', 'error') | ||||||
|  |     return | ||||||
|  |   } | ||||||
|  |   try { | ||||||
|  |     saving.value = true | ||||||
|  |     const res = await fetch('/auth/api/user/display-name', { | ||||||
|  |       method: 'PUT', | ||||||
|  |       headers: { 'content-type': 'application/json' }, | ||||||
|  |       body: JSON.stringify({ display_name: name }) | ||||||
|  |     }) | ||||||
|  |     const data = await res.json() | ||||||
|  |     if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed') | ||||||
|  |   showNameDialog.value = false | ||||||
|  |     await authStore.loadUserInfo() | ||||||
|  |     authStore.showMessage('Name updated successfully!', 'success', 3000) | ||||||
|  |   } catch (e) { | ||||||
|  |     authStore.showMessage(e.message || 'Failed to update name', 'error') | ||||||
|  |   } finally { | ||||||
|  |     saving.value = false | ||||||
|  |   } | ||||||
|  | } | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <style scoped> | <style scoped> | ||||||
| /* Removed inline user info styles; now provided by UserBasicInfo component */ | .view-lede { | ||||||
| .admin-link { |   margin: 0; | ||||||
|   font-size: 0.6em; |   color: var(--color-text-muted); | ||||||
|   margin-left: 0.75rem; |   font-size: 1rem; | ||||||
|   text-decoration: none; |  | ||||||
|   background: var(--color-background-soft, #eee); |  | ||||||
|   padding: 0.2em 0.6em; |  | ||||||
|   border-radius: 999px; |  | ||||||
|   border: 1px solid var(--color-border, #ccc); |  | ||||||
|   vertical-align: middle; |  | ||||||
|   line-height: 1.2; |  | ||||||
| } | } | ||||||
| .admin-link:hover { |  | ||||||
|   background: var(--color-background-mute, #ddd); | .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> | </style> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,26 +1,37 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="container"> |   <div class="dialog-backdrop"> | ||||||
|     <div class="view active"> |     <div class="dialog-container"> | ||||||
|       <h1>🔑 Add New Credential</h1> |       <div class="dialog-content"> | ||||||
|       <label class="name-edit"> |         <header class="view-header"> | ||||||
|         <span>👤 Name:</span> |           <h1>🔑 Add New Credential</h1> | ||||||
|         <input |           <p class="view-lede"> | ||||||
|           type="text" |             Finish setting up your passkey to complete {{ authStore.userInfo?.session_type }}. | ||||||
|           v-model="user_name" |           </p> | ||||||
|           :placeholder="authStore.userInfo?.user?.user_name || 'Your name'" |         </header> | ||||||
|           :disabled="authStore.isLoading" |         <section class="section-block"> | ||||||
|           maxlength="64" |           <div class="section-body"> | ||||||
|           @keyup.enter="register" |             <label class="name-edit"> | ||||||
|         /> |               <span>👤 Name</span> | ||||||
|       </label> |               <input | ||||||
|       <p>Proceed to complete {{authStore.userInfo?.session_type}}:</p> |                 type="text" | ||||||
|       <button |                 v-model="user_name" | ||||||
|         class="btn-primary" |                 :placeholder="authStore.userInfo?.user?.user_name || 'Your name'" | ||||||
|         :disabled="authStore.isLoading" |                 :disabled="authStore.isLoading" | ||||||
|         @click="register" |                 maxlength="64" | ||||||
|       > |                 @keyup.enter="register" | ||||||
|         {{ authStore.isLoading ? 'Registering...' : 'Register Passkey' }} |               /> | ||||||
|       </button> |             </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> | ||||||
|  |         </section> | ||||||
|  |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
| @@ -31,21 +42,20 @@ import passkey from '@/utils/passkey' | |||||||
| import { ref } from 'vue' | import { ref } from 'vue' | ||||||
|  |  | ||||||
| const authStore = useAuthStore() | const authStore = useAuthStore() | ||||||
| const user_name = ref('') // intentionally blank; original shown via placeholder | const user_name = ref('') | ||||||
|  |  | ||||||
| async function register() { | async function register() { | ||||||
|   authStore.isLoading = true |   authStore.isLoading = true | ||||||
|   authStore.showMessage('Starting registration...', 'info') |   authStore.showMessage('Starting registration...', 'info') | ||||||
|  |  | ||||||
|   try { |   try { | ||||||
|   const result = await passkey.register(authStore.resetToken, user_name.value) |     const result = await passkey.register(authStore.resetToken, user_name.value) | ||||||
|   console.log("Result", result) |     console.log('Result', result) | ||||||
|   await authStore.setSessionCookie(result.session_token) |     await authStore.setSessionCookie(result.session_token) | ||||||
|   // resetToken cleared by setSessionCookie; ensure again |     authStore.resetToken = null | ||||||
|   authStore.resetToken = null |     authStore.showMessage('Passkey registered successfully!', 'success', 2000) | ||||||
|   authStore.showMessage('Passkey registered successfully!', 'success', 2000) |     await authStore.loadUserInfo() | ||||||
|   await authStore.loadUserInfo() |     authStore.selectView() | ||||||
|   authStore.selectView() |  | ||||||
|   } catch (error) { |   } catch (error) { | ||||||
|     authStore.showMessage(`Registration failed: ${error.message}`, 'error') |     authStore.showMessage(`Registration failed: ${error.message}`, 'error') | ||||||
|   } finally { |   } finally { | ||||||
| @@ -53,3 +63,32 @@ async function register() { | |||||||
|   } |   } | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .view-lede { | ||||||
|  |   margin: 0; | ||||||
|  |   color: var(--color-text-muted); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .name-edit { | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   gap: 0.45rem; | ||||||
|  |   font-weight: 600; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .name-edit span { | ||||||
|  |   color: var(--color-text-muted); | ||||||
|  |   font-size: 0.9rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .section-body { | ||||||
|  |   gap: 1.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @media (max-width: 720px) { | ||||||
|  |   button { | ||||||
|  |     width: 100%; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </style> | ||||||
|   | |||||||
| @@ -2,21 +2,9 @@ | |||||||
|   <div v-if="userLoaded" class="user-info"> |   <div v-if="userLoaded" class="user-info"> | ||||||
|     <h3 class="user-name-heading"> |     <h3 class="user-name-heading"> | ||||||
|       <span class="icon">👤</span> |       <span class="icon">👤</span> | ||||||
|       <span v-if="!editingName" class="user-name-row"> |       <span class="user-name-row"> | ||||||
|         <span class="display-name" :title="name">{{ name }}</span> |         <span class="display-name" :title="name">{{ name }}</span> | ||||||
|         <button v-if="canEdit && updateEndpoint" class="mini-btn" @click="startEdit" title="Edit name">✏️</button> |         <button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('editName')" title="Edit name">✏️</button> | ||||||
|       </span> |  | ||||||
|       <span v-else class="user-name-row editing"> |  | ||||||
|         <input |  | ||||||
|           v-model="newName" |  | ||||||
|           class="name-input" |  | ||||||
|           :placeholder="name" |  | ||||||
|           :disabled="busy || loading" |  | ||||||
|           maxlength="64" |  | ||||||
|           @keyup.enter="saveName" |  | ||||||
|         /> |  | ||||||
|         <button class="mini-btn" @click="saveName" :disabled="busy || loading" title="Save name">💾</button> |  | ||||||
|         <button class="mini-btn" @click="cancelEdit" :disabled="busy || loading" title="Cancel">✖</button> |  | ||||||
|       </span> |       </span> | ||||||
|     </h3> |     </h3> | ||||||
|     <div v-if="orgDisplayName || roleName" class="org-role-sub"> |     <div v-if="orgDisplayName || roleName" class="org-role-sub"> | ||||||
| @@ -49,53 +37,29 @@ const props = defineProps({ | |||||||
|   roleName: { type: String, default: '' } |   roleName: { type: String, default: '' } | ||||||
| }) | }) | ||||||
|  |  | ||||||
| const emit = defineEmits(['saved']) | const emit = defineEmits(['saved', 'editName']) | ||||||
| const authStore = useAuthStore() | const authStore = useAuthStore() | ||||||
|  |  | ||||||
| const editingName = ref(false) |  | ||||||
| const newName = ref('') |  | ||||||
| const busy = ref(false) |  | ||||||
| const userLoaded = computed(() => !!props.name) | const userLoaded = computed(() => !!props.name) | ||||||
|  |  | ||||||
| function startEdit() { editingName.value = true; newName.value = '' } |  | ||||||
| function cancelEdit() { editingName.value = false } |  | ||||||
| async function saveName() { |  | ||||||
|   if (!props.updateEndpoint) { editingName.value = false; return } |  | ||||||
|   try { |  | ||||||
|     busy.value = true |  | ||||||
|     authStore.isLoading = true |  | ||||||
|     const bodyName = newName.value.trim() |  | ||||||
|     if (!bodyName) { cancelEdit(); return } |  | ||||||
|     const res = await fetch(props.updateEndpoint, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: bodyName }) }) |  | ||||||
|     let data = {} |  | ||||||
|     try { data = await res.json() } catch (_) {} |  | ||||||
|     if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed') |  | ||||||
|     editingName.value = false |  | ||||||
|     authStore.showMessage('Name updated', 'success', 1500) |  | ||||||
|     emit('saved') |  | ||||||
|   } catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') } |  | ||||||
|   finally { busy.value = false; authStore.isLoading = false } |  | ||||||
| } |  | ||||||
| watch(() => props.name, () => { if (!props.name) editingName.value = false }) |  | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <style scoped> | <style scoped> | ||||||
| .user-info { display: grid; grid-template-columns: auto 1fr; gap: 10px; } | .user-info { display: grid; grid-template-columns: auto 1fr; gap: 10px; } | ||||||
| .user-info h3 { grid-column: span 2; } | .user-info h3 { grid-column: span 2; } | ||||||
| .org-role-sub { grid-column: span 2; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; } | .org-role-sub { grid-column: span 2; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; } | ||||||
| .org-line { font-size: .7rem; font-weight:600; line-height:1.1; } | .org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; } | ||||||
| .role-line { font-size:.6rem; color:#555; line-height:1.1; } | .role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; } | ||||||
| .user-info span { text-align: left; } | .user-info span { text-align: left; } | ||||||
| .user-name-heading { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0 0 0.25rem 0; } | .user-name-heading { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0 0 0.25rem 0; } | ||||||
| .user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; } | .user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; } | ||||||
| .user-name-row.editing { flex: 1 1 auto; } | .user-name-row.editing { flex: 1 1 auto; } | ||||||
| .icon { flex: 0 0 auto; } | .icon { flex: 0 0 auto; } | ||||||
| .display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | .display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | ||||||
| .name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid #a9c5d6; border-radius: 6px; } | .name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid var(--color-border-strong); border-radius: 6px; background: var(--color-surface); color: var(--color-text); } | ||||||
| .user-name-heading .name-input { width: auto; } | .user-name-heading .name-input { width: auto; } | ||||||
| .name-input:focus { outline: 2px solid #667eea55; border-color: #667eea; } | .name-input:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); } | ||||||
| .mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; background: #eef5fa; border: 1px solid #b7d2e3; border-radius: 6px; cursor: pointer; transition: background 0.2s, transform 0.15s; } | .mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; background: var(--color-surface-muted); border: 1px solid var(--color-border-strong); border-radius: 6px; cursor: pointer; transition: background 0.2s, transform 0.15s, color 0.2s ease; color: var(--color-text); } | ||||||
| .mini-btn:hover:not(:disabled) { background: #dcecf6; } | .mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); } | ||||||
| .mini-btn:active:not(:disabled) { transform: translateY(1px); } | .mini-btn:active:not(:disabled) { transform: translateY(1px); } | ||||||
| .mini-btn:disabled { opacity: 0.5; cursor: not-allowed; } | .mini-btn:disabled { opacity: 0.5; cursor: not-allowed; } | ||||||
| @media (max-width: 480px) { .user-name-heading { flex-direction: column; align-items: flex-start; } .user-name-row.editing { width: 100%; } .display-name { max-width: 100%; } } | @media (max-width: 480px) { .user-name-heading { flex-direction: column; align-items: flex-start; } .user-name-row.editing { width: 100%; } .display-name { max-width: 100%; } } | ||||||
|   | |||||||
| @@ -130,6 +130,7 @@ export const useAuthStore = defineStore('auth', { | |||||||
|     async logout() { |     async logout() { | ||||||
|       try { |       try { | ||||||
|         await fetch('/auth/api/logout', {method: 'POST'}) |         await fetch('/auth/api/logout', {method: 'POST'}) | ||||||
|  |         sessionStorage.clear() | ||||||
|         location.reload() |         location.reload() | ||||||
|       } catch (error) { |       } catch (error) { | ||||||
|         console.error('Logout error:', error) |         console.error('Logout error:', error) | ||||||
|   | |||||||
| @@ -17,6 +17,7 @@ from sqlalchemy import ( | |||||||
|     String, |     String, | ||||||
|     delete, |     delete, | ||||||
|     event, |     event, | ||||||
|  |     insert, | ||||||
|     select, |     select, | ||||||
|     update, |     update, | ||||||
| ) | ) | ||||||
| @@ -971,8 +972,10 @@ class DB(DatabaseInterface): | |||||||
|             ) |             ) | ||||||
|             if role.permissions: |             if role.permissions: | ||||||
|                 for perm_id in set(role.permissions): |                 for perm_id in set(role.permissions): | ||||||
|                     session.add( |                     await session.execute( | ||||||
|                         RolePermission(role_uuid=role.uuid.bytes, permission_id=perm_id) |                         insert(RolePermission).values( | ||||||
|  |                             role_uuid=role.uuid.bytes, permission_id=perm_id | ||||||
|  |                         ) | ||||||
|                     ) |                     ) | ||||||
|  |  | ||||||
|     async def delete_role(self, role_uuid: UUID) -> None: |     async def delete_role(self, role_uuid: UUID) -> None: | ||||||
| @@ -1200,10 +1203,15 @@ class DB(DatabaseInterface): | |||||||
|             org_perm_result = await session.execute(org_perm_stmt) |             org_perm_result = await session.execute(org_perm_stmt) | ||||||
|             organization.permissions = [row[0] for row in org_perm_result.fetchall()] |             organization.permissions = [row[0] for row in org_perm_result.fetchall()] | ||||||
|  |  | ||||||
|  |             # Filter effective permissions: only include permissions that the org can grant | ||||||
|  |             effective_permissions = [ | ||||||
|  |                 p for p in permissions if p.id in organization.permissions | ||||||
|  |             ] | ||||||
|  |  | ||||||
|             return SessionContext( |             return SessionContext( | ||||||
|                 session=session_obj, |                 session=session_obj, | ||||||
|                 user=user_obj, |                 user=user_obj, | ||||||
|                 org=organization, |                 org=organization, | ||||||
|                 role=role, |                 role=role, | ||||||
|                 permissions=permissions if permissions else None, |                 permissions=effective_permissions if effective_permissions else None, | ||||||
|             ) |             ) | ||||||
|   | |||||||
| @@ -77,12 +77,24 @@ async def admin_list_orgs(auth=Cookie(None)): | |||||||
| async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)): | async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)): | ||||||
|     await authz.verify(auth, ["auth:admin"]) |     await authz.verify(auth, ["auth:admin"]) | ||||||
|     from ..db import Org as OrgDC  # local import to avoid cycles |     from ..db import Org as OrgDC  # local import to avoid cycles | ||||||
|  |     from ..db import Role as RoleDC  # local import to avoid cycles | ||||||
|  |  | ||||||
|     org_uuid = uuid4() |     org_uuid = uuid4() | ||||||
|     display_name = payload.get("display_name") or "New Organization" |     display_name = payload.get("display_name") or "New Organization" | ||||||
|     permissions = payload.get("permissions") or [] |     permissions = payload.get("permissions") or [] | ||||||
|     org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) |     org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) | ||||||
|     await db.instance.create_organization(org) |     await db.instance.create_organization(org) | ||||||
|  |  | ||||||
|  |     # Automatically create Administration role with org admin permission | ||||||
|  |     role_uuid = uuid4() | ||||||
|  |     admin_role = RoleDC( | ||||||
|  |         uuid=role_uuid, | ||||||
|  |         org_uuid=org_uuid, | ||||||
|  |         display_name="Administration", | ||||||
|  |         permissions=[f"auth:org:{org_uuid}"], | ||||||
|  |     ) | ||||||
|  |     await db.instance.create_role(admin_role) | ||||||
|  |  | ||||||
|     return {"uuid": str(org_uuid)} |     return {"uuid": str(org_uuid)} | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -90,7 +102,7 @@ async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)): | |||||||
| async def admin_update_org( | async def admin_update_org( | ||||||
|     org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) |     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 |         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any | ||||||
|     ) |     ) | ||||||
|     from ..db import Org as OrgDC  # local import to avoid cycles |     from ..db import Org as OrgDC  # local import to avoid cycles | ||||||
| @@ -98,6 +110,20 @@ async def admin_update_org( | |||||||
|     current = await db.instance.get_organization(str(org_uuid)) |     current = await db.instance.get_organization(str(org_uuid)) | ||||||
|     display_name = payload.get("display_name") or current.display_name |     display_name = payload.get("display_name") or current.display_name | ||||||
|     permissions = payload.get("permissions") or current.permissions or [] |     permissions = payload.get("permissions") or current.permissions or [] | ||||||
|  |  | ||||||
|  |     # Sanity check: prevent removing permissions that would break current user's admin access | ||||||
|  |     org_admin_perm = f"auth:org:{org_uuid}" | ||||||
|  |  | ||||||
|  |     # If current user is org admin (not global admin), ensure org admin perm remains | ||||||
|  |     if ( | ||||||
|  |         "auth:admin" not in ctx.role.permissions | ||||||
|  |         and f"auth:org:{org_uuid}" in ctx.role.permissions | ||||||
|  |     ): | ||||||
|  |         if org_admin_perm not in permissions: | ||||||
|  |             raise ValueError( | ||||||
|  |                 "Cannot remove organization admin permission from your own organization" | ||||||
|  |             ) | ||||||
|  |  | ||||||
|     org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) |     org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) | ||||||
|     await db.instance.update_organization(org) |     await db.instance.update_organization(org) | ||||||
|     return {"status": "ok"} |     return {"status": "ok"} | ||||||
| @@ -110,6 +136,21 @@ async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)): | |||||||
|     ) |     ) | ||||||
|     if ctx.org.uuid == org_uuid: |     if ctx.org.uuid == org_uuid: | ||||||
|         raise ValueError("Cannot delete the organization you belong to") |         raise ValueError("Cannot delete the organization you belong to") | ||||||
|  |  | ||||||
|  |     # Delete organization-specific permissions | ||||||
|  |     org_perm_pattern = f"org:{str(org_uuid).lower()}" | ||||||
|  |     all_permissions = await db.instance.list_permissions() | ||||||
|  |     for perm in all_permissions: | ||||||
|  |         perm_id_lower = perm.id.lower() | ||||||
|  |         # Check if permission contains "org:{uuid}" separated by colons or at boundaries | ||||||
|  |         if ( | ||||||
|  |             f":{org_perm_pattern}:" in perm_id_lower | ||||||
|  |             or perm_id_lower.startswith(f"{org_perm_pattern}:") | ||||||
|  |             or perm_id_lower.endswith(f":{org_perm_pattern}") | ||||||
|  |             or perm_id_lower == org_perm_pattern | ||||||
|  |         ): | ||||||
|  |             await db.instance.delete_permission(perm.id) | ||||||
|  |  | ||||||
|     await db.instance.delete_organization(org_uuid) |     await db.instance.delete_organization(org_uuid) | ||||||
|     return {"status": "ok"} |     return {"status": "ok"} | ||||||
|  |  | ||||||
| @@ -139,7 +180,9 @@ async def admin_remove_org_permission( | |||||||
| async def admin_create_role( | async def admin_create_role( | ||||||
|     org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) |     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 |     from ..db import Role as RoleDC | ||||||
|  |  | ||||||
|     role_uuid = uuid4() |     role_uuid = uuid4() | ||||||
| @@ -166,7 +209,7 @@ async def admin_update_role( | |||||||
|     org_uuid: UUID, role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) |     org_uuid: UUID, role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) | ||||||
| ): | ): | ||||||
|     # Verify caller is global admin or admin of provided org |     # Verify caller is global admin or admin of provided org | ||||||
|     await authz.verify( |     ctx = await authz.verify( | ||||||
|         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any |         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any | ||||||
|     ) |     ) | ||||||
|     role = await db.instance.get_role(role_uuid) |     role = await db.instance.get_role(role_uuid) | ||||||
| @@ -175,13 +218,25 @@ async def admin_update_role( | |||||||
|     from ..db import Role as RoleDC |     from ..db import Role as RoleDC | ||||||
|  |  | ||||||
|     display_name = payload.get("display_name") or role.display_name |     display_name = payload.get("display_name") or role.display_name | ||||||
|     permissions = payload.get("permissions") or role.permissions |     permissions = payload.get("permissions") | ||||||
|  |     if permissions is None: | ||||||
|  |         permissions = role.permissions | ||||||
|     org = await db.instance.get_organization(str(org_uuid)) |     org = await db.instance.get_organization(str(org_uuid)) | ||||||
|     grantable = set(org.permissions or []) |     grantable = set(org.permissions or []) | ||||||
|  |     existing_permissions = set(role.permissions) | ||||||
|     for pid in permissions: |     for pid in permissions: | ||||||
|         await db.instance.get_permission(pid) |         await db.instance.get_permission(pid) | ||||||
|         if pid not in grantable: |         if pid not in existing_permissions and pid not in grantable: | ||||||
|             raise ValueError(f"Permission not grantable by org: {pid}") |             raise ValueError(f"Permission not grantable by org: {pid}") | ||||||
|  |  | ||||||
|  |     # Sanity check: prevent admin from removing their own access via role update | ||||||
|  |     if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid: | ||||||
|  |         has_admin_access = ( | ||||||
|  |             "auth:admin" in permissions or f"auth:org:{org_uuid}" in permissions | ||||||
|  |         ) | ||||||
|  |         if not has_admin_access: | ||||||
|  |             raise ValueError("Cannot update your own role to remove admin permissions") | ||||||
|  |  | ||||||
|     updated = RoleDC( |     updated = RoleDC( | ||||||
|         uuid=role_uuid, |         uuid=role_uuid, | ||||||
|         org_uuid=org_uuid, |         org_uuid=org_uuid, | ||||||
| @@ -194,12 +249,17 @@ async def admin_update_role( | |||||||
|  |  | ||||||
| @app.delete("/orgs/{org_uuid}/roles/{role_uuid}") | @app.delete("/orgs/{org_uuid}/roles/{role_uuid}") | ||||||
| async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)): | async def admin_delete_role(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 |         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any | ||||||
|     ) |     ) | ||||||
|     role = await db.instance.get_role(role_uuid) |     role = await db.instance.get_role(role_uuid) | ||||||
|     if role.org_uuid != org_uuid: |     if role.org_uuid != org_uuid: | ||||||
|         raise HTTPException(status_code=404, detail="Role not found in organization") |         raise HTTPException(status_code=404, detail="Role not found in organization") | ||||||
|  |  | ||||||
|  |     # Sanity check: prevent admin from deleting their own role | ||||||
|  |     if ctx.role.uuid == role_uuid: | ||||||
|  |         raise ValueError("Cannot delete your own role") | ||||||
|  |  | ||||||
|     await db.instance.delete_role(role_uuid) |     await db.instance.delete_role(role_uuid) | ||||||
|     return {"status": "ok"} |     return {"status": "ok"} | ||||||
|  |  | ||||||
| @@ -240,7 +300,7 @@ async def admin_create_user( | |||||||
| async def admin_update_user_role( | async def admin_update_user_role( | ||||||
|     org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) |     org_uuid: UUID, user_uuid: UUID, 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 |         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any | ||||||
|     ) |     ) | ||||||
|     new_role = payload.get("role") |     new_role = payload.get("role") | ||||||
| @@ -255,6 +315,20 @@ async def admin_update_user_role( | |||||||
|     roles = await db.instance.get_roles_by_organization(str(org_uuid)) |     roles = await db.instance.get_roles_by_organization(str(org_uuid)) | ||||||
|     if not any(r.display_name == new_role for r in roles): |     if not any(r.display_name == new_role for r in roles): | ||||||
|         raise ValueError("Role not found in organization") |         raise ValueError("Role not found in organization") | ||||||
|  |  | ||||||
|  |     # Sanity check: prevent admin from removing their own access | ||||||
|  |     if ctx.user.uuid == user_uuid: | ||||||
|  |         new_role_obj = next((r for r in roles if r.display_name == new_role), None) | ||||||
|  |         if new_role_obj: | ||||||
|  |             has_admin_access = ( | ||||||
|  |                 "auth:admin" in new_role_obj.permissions | ||||||
|  |                 or f"auth:org:{org_uuid}" in new_role_obj.permissions | ||||||
|  |             ) | ||||||
|  |             if not has_admin_access: | ||||||
|  |                 raise ValueError( | ||||||
|  |                     "Cannot change your own role to one without admin permissions" | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|     await db.instance.update_user_role_in_organization(user_uuid, new_role) |     await db.instance.update_user_role_in_organization(user_uuid, new_role) | ||||||
|     return {"status": "ok"} |     return {"status": "ok"} | ||||||
|  |  | ||||||
| @@ -370,14 +444,44 @@ async def admin_update_user_display_name( | |||||||
|     return {"status": "ok"} |     return {"status": "ok"} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}") | ||||||
|  | async def admin_delete_user_credential( | ||||||
|  |     org_uuid: UUID, user_uuid: UUID, credential_uuid: UUID, 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) -------------------- | # -------------------- Permissions (global) -------------------- | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.get("/permissions") | @app.get("/permissions") | ||||||
| async def admin_list_permissions(auth=Cookie(None)): | 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() |     perms = await db.instance.list_permissions() | ||||||
|     return [{"id": p.id, "display_name": p.display_name} for p in perms] |  | ||||||
|  |     # Global admins see all permissions | ||||||
|  |     if "auth:admin" in ctx.role.permissions: | ||||||
|  |         return [{"id": p.id, "display_name": p.display_name} for p in perms] | ||||||
|  |  | ||||||
|  |     # Org admins only see permissions their org can grant | ||||||
|  |     grantable = set(ctx.org.permissions or []) | ||||||
|  |     filtered_perms = [p for p in perms if p.id in grantable] | ||||||
|  |     return [{"id": p.id, "display_name": p.display_name} for p in filtered_perms] | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.post("/permissions") | @app.post("/permissions") | ||||||
| @@ -418,6 +522,11 @@ async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)): | |||||||
|     display_name = payload.get("display_name") |     display_name = payload.get("display_name") | ||||||
|     if not old_id or not new_id: |     if not old_id or not new_id: | ||||||
|         raise ValueError("old_id and new_id required") |         raise ValueError("old_id and new_id required") | ||||||
|  |  | ||||||
|  |     # Sanity check: prevent renaming critical permissions | ||||||
|  |     if old_id == "auth:admin": | ||||||
|  |         raise ValueError("Cannot rename the master admin permission") | ||||||
|  |  | ||||||
|     querysafe.assert_safe(old_id, field="old_id") |     querysafe.assert_safe(old_id, field="old_id") | ||||||
|     querysafe.assert_safe(new_id, field="new_id") |     querysafe.assert_safe(new_id, field="new_id") | ||||||
|     if display_name is None: |     if display_name is None: | ||||||
| @@ -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)): | async def admin_delete_permission(permission_id: str, auth=Cookie(None)): | ||||||
|     await authz.verify(auth, ["auth:admin"]) |     await authz.verify(auth, ["auth:admin"]) | ||||||
|     querysafe.assert_safe(permission_id, field="permission_id") |     querysafe.assert_safe(permission_id, field="permission_id") | ||||||
|  |  | ||||||
|  |     # Sanity check: prevent deleting critical permissions | ||||||
|  |     if permission_id == "auth:admin": | ||||||
|  |         raise ValueError("Cannot delete the master admin permission") | ||||||
|  |  | ||||||
|     await db.instance.delete_permission(permission_id) |     await db.instance.delete_permission(permission_id) | ||||||
|     return {"status": "ok"} |     return {"status": "ok"} | ||||||
|   | |||||||
| @@ -193,10 +193,9 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)): | |||||||
|         } |         } | ||||||
|         effective_permissions = [p.id for p in (ctx.permissions or [])] |         effective_permissions = [p.id for p in (ctx.permissions or [])] | ||||||
|         is_global_admin = "auth:admin" in (role_info["permissions"] or []) |         is_global_admin = "auth:admin" in (role_info["permissions"] or []) | ||||||
|         if org_info: |         is_org_admin = any( | ||||||
|             is_org_admin = f"auth:org:{org_info['uuid']}" in ( |             p.startswith("auth:org:") for p in (role_info["permissions"] or []) | ||||||
|                 role_info["permissions"] or [] |         ) | ||||||
|             ) |  | ||||||
|  |  | ||||||
|     return { |     return { | ||||||
|         "authenticated": True, |         "authenticated": True, | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ build-backend = "hatchling.build" | |||||||
|  |  | ||||||
| [project] | [project] | ||||||
| name = "passkey" | name = "passkey" | ||||||
| version = "0.1.0" | version = "0.2.0" | ||||||
| description = "Passkey Authentication for Web Services" | description = "Passkey Authentication for Web Services" | ||||||
| authors = [ | authors = [ | ||||||
|     {name = "Leo Vasanko"}, |     {name = "Leo Vasanko"}, | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user