11 Commits

Author SHA1 Message Date
Leo Vasanko
eb38995cca Version 0.2.0 2025-09-30 17:04:53 -06:00
Leo Vasanko
382341e5ee Make the login/reset/forbidden dialogs look better. 2025-09-30 17:03:51 -06:00
Leo Vasanko
ed7d3ee0fc Admin app: guard rails extended, consistent styling, also share styling with main app. 2025-09-30 16:38:14 -06:00
Leo Vasanko
3dff459068 Remove duplicate message from permission denied page. 2025-09-30 12:56:41 -06:00
Leo Vasanko
89b40cd080 Admin app divided to separate components. 2025-09-30 12:54:18 -06:00
Leo Vasanko
d46d50b91a Massive style redesign, WIP. 2025-09-29 21:02:49 -06:00
Leo Vasanko
39beb31347 Simplified Caddy snippets (removed auth/all). 2025-09-28 20:00:19 -06:00
Leo Vasanko
41e6eb9a5a Version 0.1.2 2025-09-28 19:46:49 -06:00
Leo Vasanko
d5bc3e773d Clear sessionStorage on logout. 2025-09-28 19:45:37 -06:00
Leo Vasanko
ac0256c366 chore: bump version to 0.1.1 2025-09-27 20:51:46 -06:00
Leo Vasanko
6439437e8b Implement breadcrumb navigation. 2025-09-27 20:47:45 -06:00
25 changed files with 2213 additions and 1189 deletions

View File

@@ -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
}
}

View File

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

View File

@@ -1,5 +1,7 @@
# Permission to use within your endpoints that need authentication/authorization, that # 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

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,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>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

@@ -1,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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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%; } }

View File

@@ -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)

View File

@@ -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,
) )

View File

@@ -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"}

View File

@@ -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,

View File

@@ -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"},