API cleanup, using msgspec structs rather than raw responses. Admin app cleanup, better breadcrumbs.

This commit is contained in:
Leo Vasanko
2026-02-13 20:09:41 +00:00
parent 00514aa86f
commit c621a14b8d
21 changed files with 505 additions and 335 deletions
+39 -27
View File
@@ -16,28 +16,47 @@ const permMatrixRef = ref(null)
const rolesGridRef = ref(null)
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)
})
return Object.entries(props.selectedOrg.roles)
.map(([uuid, r]) => ({ uuid, ...r }))
.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)
})
})
// Get users for a role as sorted array of { uuid, ...user }
function roleUsers(roleUuid) {
return Object.entries(props.selectedOrg.users)
.filter(([_, u]) => u.role === roleUuid)
.map(([uuid, u]) => ({ uuid, ...u }))
.sort((a, b) => {
const nameA = a.display_name.toLowerCase()
const nameB = b.display_name.toLowerCase()
return nameA.localeCompare(nameB)
})
}
function roleUserCount(roleUuid) {
return Object.values(props.selectedOrg.users).filter(u => u.role === roleUuid).length
}
// Get org's grantable permissions as full permission objects (with UUIDs)
const orgPermissions = computed(() => {
const uuidSet = new Set(props.selectedOrg.permissions || [])
return props.permissions.filter(p => uuidSet.has(p.uuid))
return Object.entries(props.selectedOrg.permissions)
.map(([uuid, p]) => ({ uuid, ...p }))
.sort((a, b) => a.scope.localeCompare(b.scope))
})
function permissionDisplayName(scope) {
return props.permissions.find(p => p.scope === scope)?.display_name || scope
}
function toggleRolePermission(role, pid, checked) {
emit('toggleRolePermission', role, pid, checked)
function toggleRolePermission(roleUuid, role, pid, checked) {
emit('toggleRolePermission', roleUuid, role, pid, checked)
}
// Handle org title header keynav
@@ -287,7 +306,7 @@ defineExpose({ focusFirstElement })
<template>
<h2 class="org-title" ref="orgTitleRef" @keydown="handleTitleKeydown" :title="selectedOrg.uuid">
<span class="org-name">{{ selectedOrg.display_name }}</span>
<span class="org-name">{{ selectedOrg.org.display_name }}</span>
<button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization"></button>
</h2>
@@ -317,8 +336,8 @@ defineExpose({ focusFirstElement })
>
<input
type="checkbox"
:checked="r.permissions.includes(p.uuid)"
@change="e => toggleRolePermission(r, p.uuid, e.target.checked)"
:checked="p.uuid in (r.permissions || {})"
@change="e => toggleRolePermission(r.uuid, r, p.uuid, e.target.checked)"
/>
</div>
<div class="matrix-cell add-role-cell" />
@@ -333,34 +352,27 @@ defineExpose({ focusFirstElement })
:key="r.uuid"
class="role-column"
@dragover="$emit('onRoleDragOver', $event)"
@drop="e => $emit('onRoleDrop', e, selectedOrg, r)"
@drop="e => $emit('onRoleDrop', e, selectedOrg, r.uuid)"
>
<div class="role-header" @keydown="e => handleRoleHeaderKeydown(e, roleIndex)">
<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>
<button v-if="r.users.length === 0" @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete role" title="Delete role"></button>
<button v-if="roleUserCount(r.uuid) === 0" @click="$emit('deleteRole', r.uuid, r)" class="icon-btn delete-icon" aria-label="Delete role" title="Delete 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">
<template v-if="roleUserCount(r.uuid) > 0">
<ul class="user-list" @keydown="handleUserListKeydown">
<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)
})"
v-for="u in roleUsers(r.uuid)"
:key="u.uuid"
class="user-chip"
tabindex="0"
draggable="true"
@dragstart="e => $emit('onUserDragStart', e, u, selectedOrg.uuid)"
@dragstart="e => $emit('onUserDragStart', e, u.uuid, selectedOrg.uuid)"
@click="$emit('openUser', u)"
@keydown.enter="$emit('openUser', u)"
:title="u.uuid"
+11 -7
View File
@@ -21,7 +21,7 @@ const permActionsRef = ref(null)
const permTableRef = ref(null)
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
const nameCompare = a.display_name.localeCompare(b.display_name)
const nameCompare = a.org.display_name.localeCompare(b.org.display_name)
return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid)
}))
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope)))
@@ -35,13 +35,17 @@ function permissionDisplayName(scope) {
}
function getRoleNames(org) {
return org.roles
return Object.values(org.roles)
.slice()
.sort((a, b) => a.display_name.localeCompare(b.display_name))
.map(r => r.display_name)
.join(', ')
}
function orgUserCount(org) {
return Object.keys(org.users).length
}
// Table navigation for both org and permissions tables
function handleTableKeydown(event, tableType) {
if (props.navigationDisabled) return
@@ -265,11 +269,11 @@ defineExpose({ focusFirstElement })
<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>
<a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.org.display_name }}</a>
<button v-if="isMasterAdmin || isOrgAdmin" @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 class="center">{{ orgUserCount(o) }}</td>
<td v-if="isMasterAdmin" class="center">
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization"></button>
</td>
@@ -291,9 +295,9 @@ defineExpose({ focusFirstElement })
v-for="o in sortedOrgs"
:key="'head-' + o.uuid"
class="grid-head org-head"
:title="o.display_name"
:title="o.org.display_name"
>
<span>{{ o.display_name }}</span>
<span>{{ o.org.display_name }}</span>
</div>
<template v-for="p in sortedPermissions" :key="p.uuid">
@@ -307,7 +311,7 @@ defineExpose({ focusFirstElement })
>
<input
type="checkbox"
:checked="o.permissions.includes(p.uuid)"
:checked="p.uuid in o.permissions"
@change="e => $emit('toggleOrgPermission', o, p.uuid, e.target.checked)"
/>
</div>
+19 -35
View File
@@ -20,7 +20,6 @@ const props = defineProps({
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut', 'deleteUser'])
const authStore = useAuthStore()
const terminatingSessions = ref({})
const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
@@ -45,7 +44,7 @@ function handleEditName() {
async function handleDelete(credential) {
try {
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.uuid}`, { method: 'DELETE' })
if (data.status === 'ok') {
emit('onUserNameSaved') // Reuse to refresh user detail
} else {
@@ -57,29 +56,15 @@ async function handleDelete(credential) {
}
async function handleTerminateSession(session) {
const sessionId = session?.id
if (!sessionId) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
const credentialUuid = session?.credential
if (!credentialUuid) return
try {
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' })
if (data.status === 'ok') {
if (data.current_session_terminated) {
sessionStorage.clear()
location.reload()
return
}
emit('refreshUserDetail') // Refresh without showing rename message
authStore.showMessage('Session terminated', 'success', 2500)
} else {
authStore.showMessage(data.detail || 'Failed to terminate session', 'error')
}
await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credentialUuid}`, { method: 'DELETE' })
emit('refreshUserDetail')
authStore.showMessage('Credential deleted', 'success', 2500)
} catch (err) {
console.error('Terminate session error', err)
authStore.showMessage(err.message || 'Failed to terminate session', 'error')
} finally {
const next = { ...terminatingSessions.value }
delete next[sessionId]
terminatingSessions.value = next
console.error('Delete credential error', err)
authStore.showMessage(err.message || 'Failed to delete credential', 'error')
}
}
@@ -180,13 +165,13 @@ defineExpose({ focusFirstElement })
<div ref="userInfoRef" @keydown="handleUserInfoKeydown">
<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"
:name="userDetail.user.display_name"
:visits="userDetail.user.visits"
:created-at="userDetail.user.created_at"
:last-seen="userDetail.user.last_seen"
:loading="loading"
:org-display-name="userDetail.org.display_name"
:role-name="userDetail.role"
:org-display-name="selectedOrg?.org?.display_name"
:role-name="selectedUser?.role_display_name"
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/display-name`"
@saved="$emit('onUserNameSaved')"
@edit-name="handleEditName"
@@ -196,7 +181,7 @@ defineExpose({ focusFirstElement })
class="btn-primary"
@click="$emit('generateUserRegistrationLink', selectedUser)"
:disabled="loading"
>{{ userDetail?.credentials?.length ? 'Recovery Link' : 'Registration Link' }}</button>
>{{ Object.keys(userDetail?.credentials || {}).length ? 'Recovery Link' : 'Registration Link' }}</button>
<button
class="btn-danger"
@click="handleDeleteUser"
@@ -215,11 +200,11 @@ defineExpose({ focusFirstElement })
<div class="section-body">
<CredentialList
ref="credentialListRef"
:credentials="userDetail.credentials"
:credentials="Object.entries(userDetail.credentials).map(([uuid, c]) => ({ ...c, uuid })).sort((a, b) => new Date(a.created_at) - new Date(b.created_at))"
:aaguid-info="userDetail.aaguid_info"
:allow-delete="true"
:hovered-credential-uuid="hoveredCredentialUuid"
:hovered-session-credential-uuid="hoveredSession?.credential"
:hovered-session-credential-uuid="hoveredSession?.credential || null"
:navigation-disabled="hasActiveModal"
@delete="handleDelete"
@credential-hover="hoveredCredentialUuid = $event"
@@ -229,8 +214,7 @@ defineExpose({ focusFirstElement })
</section>
<SessionList
ref="sessionListRef"
:sessions="userDetail.sessions || []"
:terminating-sessions="terminatingSessions"
:sessions="userDetail.sessions"
:hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal"
:empty-message="'This user has no active sessions.'"
@@ -246,7 +230,7 @@ defineExpose({ focusFirstElement })
<RegistrationLinkModal
v-if="showRegModal"
:endpoint="`/auth/api/admin/users/${selectedUser.uuid}/create-link`"
:user-name="userDetail?.display_name || selectedUser.display_name"
:user-name="userDetail?.user.display_name || selectedUser.display_name"
@close="$emit('closeRegModal')"
@copied="onLinkCopied"
/>
+8 -8
View File
@@ -5,16 +5,16 @@
<template v-else>
<div
v-for="credential in credentials"
:key="credential.credential"
:key="credential.uuid"
:class="['credential-item', {
'current-session': credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid,
'is-hovered': hoveredCredentialUuid === credential.credential,
'is-linked-session': hoveredSessionCredentialUuid === credential.credential
'is-hovered': hoveredCredentialUuid === credential.uuid,
'is-linked-session': hoveredSessionCredentialUuid === credential.uuid
}]"
tabindex="-1"
@mousedown.prevent
@click.capture="handleCardClick"
@focusin="handleCredentialFocus(credential.credential)"
@focusin="handleCredentialFocus(credential.uuid)"
@focusout="handleCredentialBlur($event)"
@keydown="handleItemKeydown($event, credential)"
>
@@ -33,8 +33,8 @@
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
<div class="item-actions">
<span v-if="credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid" class="badge badge-current">Current</span>
<span v-else-if="hoveredCredentialUuid === credential.credential" class="badge badge-current">Selected</span>
<span v-else-if="hoveredSessionCredentialUuid === credential.credential" class="badge badge-current">Linked</span>
<span v-else-if="hoveredCredentialUuid === credential.uuid" class="badge badge-current">Selected</span>
<span v-else-if="hoveredSessionCredentialUuid === credential.uuid" class="badge badge-current">Linked</span>
<button
v-if="allowDelete"
@click="$emit('delete', credential)"
@@ -147,9 +147,9 @@ const getCredentialAuthName = (credential) => {
const getCredentialAuthIcon = (credential) => {
const info = props.aaguidInfo?.[credential.aaguid]
if (!info) return null
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const isDarkMode = document.documentElement.classList.contains('dark')
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
return info[iconKey] || null
return info[iconKey] || info.icon || null
}
</script>
+28 -26
View File
@@ -5,7 +5,6 @@
<ThemeSelector />
</div>
<header class="view-header">
<h1>User Profile</h1>
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p>
</header>
@@ -13,12 +12,12 @@
<section class="section-block section-block--constrained" ref="userInfoSection">
<UserBasicInfo
v-if="authStore.userInfo?.ctx"
v-if="authStore.userInfo?.user"
ref="userBasicInfo"
:name="authStore.userInfo.ctx.user.display_name"
:visits="authStore.userInfo.visits"
:created-at="authStore.userInfo.created_at"
:last-seen="authStore.userInfo.last_seen"
:name="authStore.userInfo.user.display_name"
:visits="authStore.userInfo.user.visits"
: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()"
@@ -48,11 +47,11 @@
<div class="section-body">
<CredentialList
ref="credentialList"
:credentials="authStore.userInfo?.credentials || []"
:credentials="credentials"
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
:loading="authStore.isLoading"
:hovered-credential-uuid="hoveredCredentialUuid"
:hovered-session-credential-uuid="hoveredSession?.credential"
:hovered-session-credential-uuid="hoveredSessionCredential"
:navigation-disabled="hasActiveModal"
allow-delete
@delete="handleDelete"
@@ -69,12 +68,11 @@
<SessionList
ref="sessionList"
:sessions="sessions"
:terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal"
:section-class="useWideLayout ? '' : 'section-block--constrained'"
@terminate="terminateSession"
@session-hover="hoveredSession = $event"
@session-hover="handleSessionHover"
@navigate-out="handleSessionNavigateOut"
section-description="You are currently signed in to the following sessions. If you don't recognize something, consider deleting not only the session but the associated passkey you suspect is compromised, as only this terminates all linked sessions and prevents logging in again."
/>
@@ -148,6 +146,7 @@ const newName = ref('')
const saving = ref(false)
const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
const hoveredSessionCredential = ref(null)
const showDeviceInfo = ref(false)
const pairingEntry = ref(null)
const credentialList = ref(null)
@@ -169,6 +168,11 @@ onMounted(() => {
onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) })
const handleSessionHover = (session) => {
hoveredSession.value = session
hoveredSessionCredential.value = session?.credential || null
}
const addNewCredential = async () => {
try {
await passkey.register(null, null, () => {
@@ -302,7 +306,7 @@ const handleLogoutButtonKeydown = (event) => {
}
const handleDelete = async (credential) => {
const credentialId = credential?.credential
const credentialId = credential?.uuid
if (!credentialId) return
try {
await authStore.deleteCredential(credentialId)
@@ -312,35 +316,33 @@ const handleDelete = async (credential) => {
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
const paskiaVersion = computed(() => authStore.settings?.version || '')
const credentials = computed(() => {
const creds = authStore.userInfo?.credentials || {}
return Object.entries(creds).map(([uuid, c]) => ({ ...c, uuid })).sort((a, b) => new Date(a.created_at) - new Date(b.created_at))
})
const sessions = computed(() => authStore.userInfo?.sessions || [])
const currentSessionHost = computed(() => {
const currentSession = sessions.value.find(session => session.is_current)
return currentSession?.host || 'this host'
})
const terminatingSessions = ref({})
const terminateSession = async (session) => {
const sessionId = session?.id
if (!sessionId) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
try { await authStore.terminateSession(sessionId) }
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
finally {
const next = { ...terminatingSessions.value }
delete next[sessionId]
terminatingSessions.value = next
if (session.is_current) {
await logout()
} else {
try { await authStore.deleteCredential(session.credential) }
catch (error) { authStore.showMessage(error.message || 'Failed to delete credential', 'error', 5000) }
}
}
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
const logout = async () => { await authStore.logout() }
const openNameDialog = () => { newName.value = authStore.userInfo?.ctx.user.display_name ?? ''; showNameDialog.value = true }
const openNameDialog = () => { newName.value = authStore.userInfo?.user.display_name ?? ''; showNameDialog.value = true }
const isAdmin = computed(() => {
const perms = authStore.userInfo?.ctx.permissions
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
const perms = authStore.ctx?.permissions
return perms?.includes('auth:admin') || perms?.includes('auth:org:admin')
})
const hasMultipleSessions = computed(() => sessions.value.length > 1)
const credentials = computed(() => authStore.userInfo?.credentials || [])
const useWideLayout = computed(() => {
// Check if any single site has more than 8 sessions
const groups = {}
@@ -356,7 +358,7 @@ const useWideLayout = computed(() => {
return hasLargeSessionGroup || hasManyCredentials
})
const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
const breadcrumbEntries = computed(() => { const entries = [{ label: 'My Profile', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
const saveName = async () => {
const name = newName.value.trim()
@@ -14,6 +14,7 @@
</p>
<QRCodeDisplay
v-if="linkUrl"
:url="linkUrl"
:show-link="true"
@copied="onCopied"
+5 -9
View File
@@ -15,11 +15,11 @@
</span>
<div class="session-list">
<div
v-for="session in group.sessions"
:key="session.id"
v-for="(session, index) in group.sessions"
:key="index"
:class="['session-item', {
'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
'is-hovered': hoveredSession?.id === session.id,
'is-hovered': hoveredSession === session,
'is-linked-credential': hoveredCredentialUuid === session.credential
}]"
tabindex="-1"
@@ -33,14 +33,13 @@
<h4 class="item-title">{{ session.user_agent || '—' }}</h4>
<div class="item-actions">
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
<span v-else-if="hoveredSession?.id === session.id" class="badge badge-current">Selected</span>
<span v-else-if="hoveredSession === session" class="badge badge-current">Selected</span>
<span v-else-if="hoveredCredentialUuid === session.credential" class="badge badge-current">Linked</span>
<span v-else-if="!hoveredCredentialUuid && isSameHost(session.ip)" class="badge">Same IP</span>
<button
@click="$emit('terminate', session)"
class="btn-card-delete"
:disabled="isTerminating(session.id)"
:title="isTerminating(session.id) ? 'Terminating...' : 'Terminate session'"
:title="'Delete associated passkey'"
tabindex="-1"
></button>
</div>
@@ -72,7 +71,6 @@ const props = defineProps({
sessions: { type: Array, default: () => [] },
emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
terminatingSessions: { type: Object, default: () => ({}) },
hoveredCredentialUuid: { type: String, default: null },
navigationDisabled: { type: Boolean, default: false },
sectionClass: { type: String, default: '' },
@@ -107,8 +105,6 @@ const handleCardClick = (event) => {
}
}
const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId]
const handleGroupKeydown = (event, host) => {
const group = event.currentTarget
const sessionList = group.querySelector('.session-list')
+2 -1
View File
@@ -8,6 +8,7 @@ export const useAuthStore = defineStore('auth', {
state: () => ({
// Auth State
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
ctx: null, // Session context from validate
isLoading: false,
// Settings
@@ -87,7 +88,7 @@ export const useAuthStore = defineStore('auth', {
async loadUserInfo() {
try {
this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
updateThemeFromSession(this.userInfo?.ctx)
updateThemeFromSession(this.ctx)
console.log('User info loaded:', this.userInfo)
} catch (error) {
// Suppress toast for 401/403 errors - the auth iframe will handle these