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

This commit is contained in:
2026-02-13 20:09:41 +00:00
parent 423abb0d1b
commit c1f8020f6b
21 changed files with 505 additions and 335 deletions
+14 -2
View File
@@ -54,7 +54,7 @@ function terminateSession() {
viewState.value = 'terminal'
}
const userUuidGetter = () => store.userInfo?.ctx.user.uuid
const userUuidGetter = () => store.ctx?.user.uuid
const sessionValidator = new SessionValidator(userUuidGetter, terminateSession)
onMounted(() => sessionValidator.start())
@@ -62,11 +62,23 @@ onUnmounted(() => sessionValidator.stop())
async function loadUserInfo() {
try {
store.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
const [validateData, userInfoData] = await Promise.all([
apiJson('/auth/api/validate', { method: 'POST' }),
apiJson('/auth/api/user-info', { method: 'POST' })
])
store.userInfo = userInfoData
store.ctx = validateData.ctx
// Verify that the user UUIDs match between user-info and validate responses
if (store.userInfo.user.uuid !== store.ctx.user.uuid) {
console.error('User UUID mismatch between user-info and validate responses')
window.location.reload()
return false
}
viewState.value = 'profile'
return true
} catch {
store.userInfo = null
store.ctx = null
return false
}
}
+82 -60
View File
@@ -79,11 +79,11 @@ onUnmounted(() => {
const permissionSummary = computed(() => {
const summary = {}
for (const o of orgs.value) {
const orgBase = { uuid: o.uuid, display_name: o.display_name }
const orgPerms = new Set(o.permissions || [])
const orgBase = { uuid: o.uuid, display_name: o.org.display_name }
const orgPerms = new Set(Object.keys(o.permissions))
// Org-level permissions (direct) - only count if org can grant them
for (const pid of o.permissions || []) {
for (const pid of Object.keys(o.permissions)) {
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
if (!summary[pid].orgSet.has(o.uuid)) {
summary[pid].orgs.push(orgBase)
@@ -92,8 +92,8 @@ const permissionSummary = computed(() => {
}
// Role-based permissions (inheritance) - only count if org can grant them
for (const r of o.roles) {
for (const pid of r.permissions) {
for (const [roleUuid, r] of Object.entries(o.roles)) {
for (const pid of Object.keys(r.permissions || {})) {
// Only count if the org can grant this permission
if (!orgPerms.has(pid)) continue
@@ -102,7 +102,7 @@ const permissionSummary = computed(() => {
summary[pid].orgs.push(orgBase)
summary[pid].orgSet.add(o.uuid)
}
summary[pid].userCount += r.users.length
summary[pid].userCount += roleUserCount(o, roleUuid)
}
}
}
@@ -129,18 +129,32 @@ function parseHash() {
async function loadOrgs() {
const data = await apiJson('/auth/api/admin/orgs')
orgs.value = data.map(o => {
const roles = o.roles.map(r => ({ ...r, org: o.uuid, users: [] }))
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r]))
for (const u of o.users || []) {
if (roleMap[u.role]) roleMap[u.role].users.push(u)
}
return { ...o, roles }
})
orgs.value = Object.entries(data).map(([uuid, o]) => ({ uuid, ...o }))
}
// Helper to get users for a role as sorted array of [uuid, user]
function roleUsers(org, roleUuid) {
return Object.entries(org.users)
.filter(([_, u]) => u.role === roleUuid)
.sort(([, a], [, b]) => {
const nameA = a.display_name.toLowerCase()
const nameB = b.display_name.toLowerCase()
return nameA.localeCompare(nameB)
})
}
// Helper to count users in a role
function roleUserCount(org, roleUuid) {
return Object.values(org.users).filter(u => u.role === roleUuid).length
}
// Helper to count total users in an org
function orgUserCount(org) {
return Object.keys(org.users).length
}
async function loadPermissions() {
permissions.value = await apiJson('/auth/api/admin/permissions')
permissions.value = Object.values(await apiJson('/auth/api/admin/permissions'))
}
async function loadUserInfo() {
@@ -186,7 +200,6 @@ async function load() {
if (!window.location.hash || window.location.hash === '#overview') {
currentOrgId.value = orgs.value[0].uuid
window.location.hash = `#org/${currentOrgId.value}`
authStore.showMessage(`Navigating to ${orgs.value[0].display_name} Administration`, 'info', 3000)
} else {
parseHash()
}
@@ -201,7 +214,7 @@ async function load() {
// Org actions
function createOrg() { openDialog('org-create', {}) }
function updateOrg(org) { openDialog('org-update', { org, name: org.display_name }) }
function updateOrg(org) { openDialog('org-update', { org, name: org.org.display_name }) }
function editUserName(user) { openDialog('user-update-name', { user, name: user.display_name }) }
@@ -211,13 +224,13 @@ async function performOrgDeletion(orgUuid) {
}
function deleteOrg(org) {
const userCount = org.roles.reduce((acc, r) => acc + r.users.length, 0)
const userCount = orgUserCount(org)
if (userCount === 0) {
// No users in the organization, safe to delete directly
performOrgDeletion(org.uuid)
.then(() => {
authStore.showMessage(`Organization "${org.display_name}" deleted.`, 'success', 2500)
authStore.showMessage(`Organization "${org.org.display_name}" deleted.`, 'success', 2500)
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to delete organization', 'error')
@@ -226,13 +239,14 @@ function deleteOrg(org) {
}
// Build detailed breakdown of users by role
const roleParts = org.roles
.filter(r => r.users.length > 0)
.map(r => `${r.users.length} ${r.display_name}`)
const roleParts = Object.entries(org.roles)
.map(([uuid, r]) => [roleUserCount(org, uuid), r.display_name])
.filter(([count]) => count > 0)
.map(([count, name]) => `${count} ${name}`)
const affects = roleParts.join(', ')
openDialog('confirm', { message: `Delete organization "${org.display_name}", including accounts of ${affects})?`, action: async () => {
openDialog('confirm', { message: `Delete organization "${org.org.display_name}", including accounts of ${affects})?`, action: async () => {
await performOrgDeletion(org.uuid)
} })
}
@@ -240,7 +254,7 @@ function deleteOrg(org) {
function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
function deleteUser(user, userDetail) {
const credentialCount = userDetail?.credentials?.length || 0
const credentialCount = Object.keys(userDetail?.credentials || {}).length
const userUuid = user.uuid
const userName = user.display_name
const orgUuid = user.org // org UUID is stored in selectedUser
@@ -271,10 +285,10 @@ async function performUserDeletion(userUuid, userName, orgUuid) {
}
}
async function moveUserToRole(user, targetRoleUuid) {
if (user.role_uuid === targetRoleUuid) return
async function moveUserToRole(userUuid, user, targetRoleUuid) {
if (user.role === targetRoleUuid) return
try {
await apiJson(`/auth/api/admin/users/${user.uuid}/role`, {
await apiJson(`/auth/api/admin/users/${userUuid}/role`, {
method: 'PATCH',
body: { role_uuid: targetRoleUuid }
})
@@ -284,9 +298,9 @@ async function moveUserToRole(user, targetRoleUuid) {
}
}
function onUserDragStart(e, user, org) {
function onUserDragStart(e, userUuid, org) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: user.uuid, org }))
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: userUuid, org }))
}
function onRoleDragOver(e) {
@@ -294,13 +308,13 @@ function onRoleDragOver(e) {
e.dataTransfer.dropEffect = 'move'
}
function onRoleDrop(e, org, role) {
function onRoleDrop(e, org, roleUuid) {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'))
if (data.org !== org.uuid) return // only within same org
const user = org.roles.flatMap(r => r.users).find(u => u.uuid === data.user_uuid)
if (user) moveUserToRole(user, role.uuid)
const user = org.users[data.user_uuid]
if (user) moveUserToRole(data.user_uuid, user, roleUuid)
} catch (_) { /* ignore */ }
}
@@ -309,9 +323,9 @@ function createRole(org) { openDialog('role-create', { org }) }
function updateRole(role) { openDialog('role-update', { role, name: role.display_name }) }
function deleteRole(role) {
function deleteRole(roleUuid, role) {
// UI only allows deleting empty roles, so no confirmation needed
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'DELETE' })
apiJson(`/auth/api/admin/roles/${roleUuid}`, { method: 'DELETE' })
.then(() => {
authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500)
loadOrgs()
@@ -321,17 +335,20 @@ function deleteRole(role) {
})
}
async function toggleRolePermission(role, pid, checked) {
async function toggleRolePermission(roleUuid, role, pid, checked) {
// Optimistic update
const prevPermissions = [...role.permissions]
const newPermissions = checked
? [...role.permissions, pid]
: role.permissions.filter(p => p !== pid)
const prevPermissions = { ...(role.permissions || {}) }
const newPermissions = { ...(role.permissions || {}) }
if (checked) {
newPermissions[pid] = true
} else {
delete newPermissions[pid]
}
role.permissions = newPermissions
try {
const method = checked ? 'POST' : 'DELETE'
await apiJson(`/auth/api/admin/roles/${role.uuid}/permissions/${pid}`, {
await apiJson(`/auth/api/admin/roles/${roleUuid}/permissions/${pid}`, {
method
})
await loadOrgs()
@@ -354,8 +371,8 @@ function deletePermission(p) {
// Count roles that have this permission
let roleCount = 0
for (const org of orgs.value) {
for (const role of org.roles) {
if (role.permissions.includes(p.uuid)) {
for (const role of Object.values(org.roles)) {
if (p.uuid in (role.permissions || {})) {
roleCount++
}
}
@@ -400,25 +417,19 @@ function openUser(u) {
const selectedUser = computed(() => {
if (!currentUserId.value) return null
for (const o of orgs.value) {
for (const r of o.roles) {
const u = r.users.find(x => x.uuid === currentUserId.value)
if (u) return { ...u, org: o.uuid, role_display_name: r.display_name }
const u = o.users[currentUserId.value]
if (u) {
const role = o.roles[u.role]
return { ...u, uuid: currentUserId.value, org: o.uuid, role_display_name: role?.display_name }
}
}
return null
})
const pageHeading = computed(() => {
if (selectedUser.value) return 'Admin: User'
if (selectedOrg.value) return 'Admin: Org'
return ((authStore.settings?.rp_name) || 'Master') + ' Admin'
})
// Breadcrumb entries for admin app.
const breadcrumbEntries = computed(() => {
const entries = [
{ label: 'Auth', href: makeUiHref() },
{ label: 'Admin', href: adminUiPath() }
{ label: 'My Profile', href: makeUiHref() }
]
// Determine organization for user view if selectedOrg not explicitly chosen.
let orgForUser = null
@@ -426,8 +437,15 @@ const breadcrumbEntries = computed(() => {
orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org) || null
}
const orgToShow = selectedOrg.value || orgForUser
if (orgToShow) {
entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` })
if (orgToShow && isOrgAdmin.value && !isMasterAdmin.value) {
// For org admins, combine Admin and org name into one link
entries.push({ label: `Admin: ${orgToShow.org.display_name}`, href: `#org/${orgToShow.uuid}` })
} else {
// For master admins or when not showing an org, separate Admin link
entries.push({ label: 'Admin', href: isMasterAdmin.value ? adminUiPath() : (orgs.value.length === 1 ? `#org/${orgs.value[0].uuid}` : adminUiPath()) })
if (orgToShow) {
entries.push({ label: orgToShow.org.display_name, href: `#org/${orgToShow.uuid}` })
}
}
if (selectedUser.value) {
entries.push({ label: selectedUser.value.display_name, href: `#user/${selectedUser.value.uuid}` })
@@ -450,13 +468,18 @@ function generateUserRegistrationLink(u) {
}
async function toggleOrgPermission(org, permId, checked) {
// Build next permission list
const has = org.permissions.includes(permId)
// Build next permission dict
const has = permId in org.permissions
if (checked && has) return
if (!checked && !has) return
const next = checked ? [...org.permissions, permId] : org.permissions.filter(p => p !== permId)
const next = { ...org.permissions }
if (checked) {
next[permId] = true // Placeholder, real data comes from loadOrgs
} else {
delete next[permId]
}
// Optimistic update
const prev = [...org.permissions]
const prev = { ...org.permissions }
org.permissions = next
try {
const params = new URLSearchParams({ permission_uuid: permId })
@@ -760,7 +783,6 @@ async function submitDialog() {
/>
<section v-else-if="authenticated && (isMasterAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
<header class="view-header">
<h1>{{ pageHeading }}</h1>
<Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
</header>
+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
+1 -1
View File
@@ -235,4 +235,4 @@ def main():
if __name__ == "__main__":
main()
main()
+13 -1
View File
@@ -30,4 +30,16 @@ def filter(aaguids: Iterable[UUID]) -> dict[str, dict]:
Dictionary mapping AAGUID string to authenticator information for only
the AAGUIDs that the user has and that we have data for
"""
return {(s := str(a)): AAGUID[s] for a in aaguids if (s := str(a)) in AAGUID}
result = {}
for a in aaguids:
s = str(a)
if s in AAGUID:
info = AAGUID[s].copy()
# Rename icon_light to icon
if "icon_light" in info:
info["icon"] = info.pop("icon_light")
# If icons are the same, set dark to None to save space
if info.get("icon") == info.get("icon_dark"):
info["icon_dark"] = None
result[s] = info
return result
+1
View File
@@ -102,6 +102,7 @@ def bootstrap(
created_at=now,
last_seen=None,
visits=0,
theme="",
)
admin_user.uuid = user_uuid
admin_user.store()
+6
View File
@@ -20,6 +20,12 @@ def migrate_v2(d: dict, *, rp_id: str = "localhost") -> None:
d["config"] = {"rp_id": rp_id}
def migrate_v3(d: dict, **kwargs) -> None:
"""Ensure all users have visits field."""
for user_data in d["users"].values():
user_data.setdefault("visits", 0)
migrations = sorted(
[f for n, f in globals().items() if n.startswith("migrate_v")],
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
+6 -3
View File
@@ -194,7 +194,7 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
return role
class User(msgspec.Struct, dict=True, omit_defaults=True):
class User(msgspec.Struct, dict=True, omit_defaults=False, kw_only=True):
"""User data structure.
Mutable fields: display_name, role_uuid, last_seen, visits, theme
@@ -205,9 +205,9 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
display_name: str
role_uuid: UUID = msgspec.field(name="role")
created_at: datetime
visits: int
last_seen: datetime | None = None
visits: int = 0
theme: str = "" # "" or "auto" = OS default, "light", "dark"
theme: str = ""
def __post_init__(self):
if not hasattr(self, "uuid"):
@@ -274,6 +274,9 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
display_name=display_name,
role_uuid=role_uuid,
created_at=created_at or datetime.now(UTC),
last_seen=None,
visits=0,
theme="",
)
user.uuid = uuid7.create(user.created_at)
return user
+51 -67
View File
@@ -21,7 +21,17 @@ from paskia.util import (
querysafe,
vitedev,
)
from paskia.util.apistructs import ApiPermission, ApiSession, format_datetime
from paskia.util.apistructs import (
ApiAaguidInfo,
ApiCreateLinkResponse,
ApiOrgResponse,
ApiPermission,
ApiUser,
ApiUserDetail,
ApiUserSession,
ApiUuidResponse,
format_datetime,
)
from paskia.util.hostutil import normalize_host
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -83,34 +93,15 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
def org_to_dict(o):
return {
"uuid": o.uuid,
"display_name": o.display_name,
"permissions": {p.uuid for p in o.permissions},
"roles": [
{
"uuid": r.uuid,
"org": r.org_uuid,
"display_name": r.display_name,
"permissions": list(r.permissions.keys()),
}
for r in o.roles
],
"users": [
{
"uuid": u.uuid,
"display_name": u.display_name,
"role": r.display_name,
"role_uuid": u.role_uuid,
"visits": u.visits,
"last_seen": u.last_seen,
}
for r in o.roles
for u in r.users
],
}
roles = o.roles
return ApiOrgResponse(
org=o,
permissions={p.uuid: p for p in o.permissions},
roles={r.uuid: r for r in roles},
users={u.uuid: u for r in roles for u in r.users},
)
return MsgspecResponse([org_to_dict(o) for o in orgs])
return MsgspecResponse({o.uuid: org_to_dict(o) for o in orgs})
@app.post("/orgs")
@@ -129,7 +120,7 @@ async def admin_create_org(
for perm in permissions:
db.add_permission_to_org(str(org.uuid), perm, ctx=ctx)
return {"uuid": str(org.uuid)}
return MsgspecResponse(ApiUuidResponse(uuid=str(org.uuid)))
@app.patch("/orgs/{org_uuid}")
@@ -278,7 +269,7 @@ async def admin_create_role(
permissions=permission_uuids,
)
db.create_role(role, ctx=ctx)
return {"uuid": str(role.uuid)}
return MsgspecResponse(ApiUuidResponse(uuid=str(role.uuid)))
@app.patch("/roles/{role_uuid}")
@@ -451,7 +442,7 @@ async def admin_create_user(
role=role_obj.uuid,
)
db.create_user(user, ctx=ctx)
return {"uuid": str(user.uuid)}
return MsgspecResponse(ApiUuidResponse(uuid=str(user.uuid)))
@app.patch("/users/{user_uuid}/role")
@@ -538,11 +529,13 @@ async def admin_create_user_registration_link(
ctx=ctx,
)
url = hostutil.reset_link_url(token)
return {
"url": url,
"expires": format_datetime(expiry),
"token_type": token_type,
}
return MsgspecResponse(
ApiCreateLinkResponse(
url=url,
expires=format_datetime(expiry),
token_type=token_type,
)
)
@app.get("/users/{user_uuid}")
@@ -553,7 +546,6 @@ async def admin_get_user_detail(
):
try:
user = db.data().users[user_uuid]
role_name = user.role.display_name
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
@@ -568,36 +560,28 @@ async def admin_get_user_detail(
)
normalized_host = hostutil.normalize_host(request.headers.get("host"))
sessions = [
ApiUserSession.from_db(
s,
current_key=auth,
normalized_host=normalized_host,
expires_delta=EXPIRES,
)
for s in user.sessions
]
return MsgspecResponse(
{
"display_name": user.display_name,
"org": {"display_name": user.org.display_name},
"role": role_name,
"visits": user.visits,
"created_at": user.created_at,
"last_seen": user.last_seen,
"credentials": [
{
"credential": c.uuid,
"aaguid": c.aaguid,
"created_at": c.created_at,
"last_used": c.last_used,
"last_verified": c.last_verified,
"sign_count": c.sign_count,
}
for c in user.credentials
],
"aaguid_info": aaguid_mod.filter(c.aaguid for c in user.credentials),
"sessions": [
ApiSession.from_db(
s,
current_key=auth,
normalized_host=normalized_host,
expires_delta=EXPIRES,
)
for s in user.sessions
],
}
ApiUserDetail(
user=ApiUser.from_db(user),
credentials={c.uuid: c for c in user.credentials},
aaguid_info={
k: ApiAaguidInfo(**v)
for k, v in aaguid_mod.filter(
c.aaguid for c in user.credentials
).items()
},
sessions=sessions,
)
)
@@ -813,7 +797,7 @@ async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
host=request.headers.get("host"),
)
perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
return MsgspecResponse([ApiPermission.from_db(p) for p in perms])
return MsgspecResponse({p.uuid: ApiPermission.from_db(p) for p in perms})
@app.post("/permissions")
+24 -18
View File
@@ -21,6 +21,7 @@ from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
from paskia.globals import passkey as global_passkey
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
from paskia.util.apistructs import ApiSettings, ApiTokenInfo, ApiValidateResponse
bearer_auth = HTTPBearer(auto_error=True)
@@ -100,11 +101,11 @@ async def validate_token(
session.set_session_cookie(response, auth)
renewed = True
return MsgspecResponse(
{
"valid": True,
"renewed": renewed,
"ctx": userinfo.build_session_context(ctx),
}
ApiValidateResponse(
valid=True,
renewed=renewed,
ctx=userinfo.build_session_context(ctx),
)
)
@@ -189,15 +190,17 @@ async def forward_authentication(
async def get_settings():
pk = global_passkey.instance
base_path = hostutil.ui_base_path()
return {
"rp_id": pk.rp_id,
"rp_name": pk.rp_name,
"ui_base_path": base_path,
"auth_host": hostutil.dedicated_auth_host(),
"auth_site_url": hostutil.auth_site_url(),
"session_cookie": AUTH_COOKIE_NAME,
"version": __version__,
}
return MsgspecResponse(
ApiSettings(
rp_id=pk.rp_id,
rp_name=pk.rp_name,
ui_base_path=base_path,
auth_host=hostutil.dedicated_auth_host(),
auth_site_url=hostutil.auth_site_url(),
session_cookie=AUTH_COOKIE_NAME,
version=__version__,
)
)
@app.post("/user-info")
@@ -223,6 +226,7 @@ async def api_user_info(
auth=auth,
session_record=ctx.session,
request_host=request.headers.get("host"),
ctx=ctx,
)
)
@@ -239,10 +243,12 @@ async def token_info(credentials=Depends(bearer_auth)):
raise HTTPException(401, str(e))
u = reset_token.user
return {
"token_type": reset_token.token_type,
"display_name": u.display_name,
}
return MsgspecResponse(
ApiTokenInfo(
token_type=reset_token.token_type,
display_name=u.display_name,
)
)
@app.post("/logout")
+1 -1
View File
@@ -9,10 +9,10 @@ from fastapi.responses import FileResponse, RedirectResponse
from fastapi_vue import Frontend
from paskia import globals
from paskia.__main__ import DEVMODE
from paskia.db import start_background, stop_background
from paskia.db.logging import configure_db_logging
from paskia.fastapi import admin, api, auth_host, ws
from paskia.__main__ import DEVMODE
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import hostutil, passphrase, vitedev
+14 -10
View File
@@ -16,8 +16,10 @@ from paskia.authsession import (
expires,
)
from paskia.fastapi import authz, session
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import hostutil
from paskia.util.apistructs import ApiCreateLinkResponse
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -157,13 +159,15 @@ async def api_create_link(
ctx=ctx,
)
url = hostutil.reset_link_url(token)
return {
"message": "Registration link generated successfully",
"url": url,
"expires": (
expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
if expiry.tzinfo
else expiry.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
),
"token_type": "device addition",
}
return MsgspecResponse(
ApiCreateLinkResponse(
message="Registration link generated successfully",
url=url,
expires=(
expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
if expiry.tzinfo
else expiry.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
),
token_type="device addition",
)
)
+127 -14
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
"""API response utilities using msgspec for JSON serialization.
msgspec handles UUID and datetime conversion automatically.
@@ -9,7 +11,7 @@ from uuid import UUID
import msgspec
from paskia.db.structs import Org, Permission, Role, User
from paskia.db.structs import Credential, Org, Permission, Role, User
from paskia.util import useragent
@@ -41,7 +43,7 @@ class ApiUser(User, kw_only=True):
uuid: UUID
@classmethod
def from_db(cls, u: User) -> "ApiUser":
def from_db(cls, u: User) -> ApiUser:
return cls(uuid=u.uuid, **msgspec.structs.asdict(u))
@@ -51,7 +53,7 @@ class ApiOrg(Org, kw_only=True):
uuid: UUID
@classmethod
def from_db(cls, o: Org) -> "ApiOrg":
def from_db(cls, o: Org) -> ApiOrg:
return cls(uuid=o.uuid, **msgspec.structs.asdict(o))
@@ -61,28 +63,42 @@ class ApiRole(Role, kw_only=True):
uuid: UUID
@classmethod
def from_db(cls, r: Role) -> "ApiRole":
def from_db(cls, r: Role) -> ApiRole:
return cls(uuid=r.uuid, **msgspec.structs.asdict(r))
class ApiPermission(Permission, kw_only=True):
"""Permission with uuid serialized."""
class ApiPermission(msgspec.Struct, kw_only=True):
"""Permission for API responses, without org details."""
uuid: UUID
scope: str
display_name: str
domain: str | None = None
@classmethod
def from_db(cls, p: Permission) -> "ApiPermission":
return cls(uuid=p.uuid, **msgspec.structs.asdict(p))
def from_db(cls, p: Permission) -> ApiPermission:
return cls(
scope=p.scope,
display_name=p.display_name,
domain=p.domain,
)
class ApiSession(msgspec.Struct):
"""Session for API responses with computed fields."""
class ApiAaguidInfo(msgspec.Struct, kw_only=True, omit_defaults=True):
"""AAGUID information for authenticators."""
name: str
icon: str | None = None
icon_dark: str | None = None
class ApiUserSession(msgspec.Struct):
"""Session for user info responses with computed fields."""
id: str
credential_uuid: UUID = msgspec.field(name="credential")
host: str
ip: str
user_agent: str
expiry: datetime
last_renewed: datetime
is_current: bool = False
is_current_host: bool = False
@@ -95,16 +111,113 @@ class ApiSession(msgspec.Struct):
current_key: str,
normalized_host: str | None,
expires_delta, # timedelta
) -> "ApiSession":
) -> ApiUserSession:
return cls(
id=s.key,
credential_uuid=s.credential_uuid,
host=s.host,
ip=s.ip,
user_agent=useragent.compact_user_agent(s.user_agent),
expiry=s.expiry,
last_renewed=s.expiry - expires_delta,
is_current=s.key == current_key,
is_current_host=bool(
normalized_host and s.host and s.host == normalized_host
),
)
class ApiUserDetail(msgspec.Struct, kw_only=True):
"""User detail response with credentials and sessions."""
user: ApiUser
credentials: dict[UUID, Credential]
aaguid_info: dict[str, ApiAaguidInfo]
sessions: list[ApiUserSession]
permissions: dict[UUID, ApiPermission] = {}
# -------------------------------------------------------------------------
# Nested API structs for org response - without uuid
# -------------------------------------------------------------------------
class ApiOrgResponse(msgspec.Struct, kw_only=True):
"""Org response containing Org with roles and users as UUID-keyed dicts."""
org: Org
permissions: dict[UUID, Permission]
roles: dict[UUID, Role]
users: dict[UUID, User]
class ApiSettings(msgspec.Struct):
"""Settings response struct."""
rp_id: str
rp_name: str
ui_base_path: str
auth_host: str | None
auth_site_url: str
session_cookie: str
version: str
class ApiTokenInfo(msgspec.Struct):
"""Token info response struct."""
token_type: str
display_name: str
class ApiUuidResponse(msgspec.Struct):
"""Response struct for creation endpoints returning a UUID."""
uuid: str
class ApiCreateLinkResponse(msgspec.Struct):
"""Response struct for create-link endpoints."""
url: str
expires: str
token_type: str
message: str | None = None
class ApiUserContext(msgspec.Struct, omit_defaults=True):
"""User context for session validation."""
uuid: str
display_name: str
theme: str = ""
class ApiOrgContext(msgspec.Struct):
"""Org context for session validation."""
uuid: str
display_name: str
class ApiRoleContext(msgspec.Struct):
"""Role context for session validation."""
uuid: str
display_name: str
class ApiSessionContext(msgspec.Struct):
"""Session context struct."""
user: ApiUserContext
org: ApiOrgContext
role: ApiRoleContext
permissions: list[str]
class ApiValidateResponse(msgspec.Struct):
"""Response struct for validate endpoint."""
valid: bool
renewed: bool
ctx: ApiSessionContext
+52 -45
View File
@@ -3,21 +3,35 @@
from paskia import aaguid, db
from paskia.authsession import EXPIRES
from paskia.db import SessionContext
from paskia.util import hostutil, permutil
from paskia.util.apistructs import ApiSession
from paskia.util import hostutil
from paskia.util.apistructs import (
ApiAaguidInfo,
ApiOrgContext,
ApiPermission,
ApiRoleContext,
ApiSessionContext,
ApiUser,
ApiUserContext,
ApiUserDetail,
ApiUserSession,
)
def build_session_context(ctx: SessionContext) -> dict:
"""Build session context dict from SessionContext."""
result = {
"user": {"uuid": ctx.user.uuid, "display_name": ctx.user.display_name},
"org": {"uuid": ctx.org.uuid, "display_name": ctx.org.display_name},
"role": {"uuid": ctx.role.uuid, "display_name": ctx.role.display_name},
"permissions": [p.scope for p in ctx.permissions],
}
if ctx.user.theme:
result["user"]["theme"] = ctx.user.theme
return result
def build_session_context(ctx: SessionContext) -> ApiSessionContext:
"""Build session context struct from SessionContext."""
user = ApiUserContext(
uuid=ctx.user.uuid,
display_name=ctx.user.display_name,
theme=ctx.user.theme,
)
org = ApiOrgContext(uuid=ctx.org.uuid, display_name=ctx.org.display_name)
role = ApiRoleContext(uuid=ctx.role.uuid, display_name=ctx.role.display_name)
return ApiSessionContext(
user=user,
org=org,
role=role,
permissions=[p.scope for p in ctx.permissions],
)
async def build_user_info(
@@ -26,38 +40,31 @@ async def build_user_info(
auth: str,
session_record,
request_host: str | None,
) -> dict:
"""Build user info dict for authenticated users."""
ctx = await permutil.session_context(auth, request_host)
ctx: SessionContext | None = None,
) -> ApiUserDetail:
"""Build user info struct for authenticated users."""
user = db.data().users[user_uuid]
normalized_host = hostutil.normalize_host(request_host)
credentials = sorted(user.credentials, key=lambda c: c.created_at)
return {
"ctx": build_session_context(ctx),
"created_at": ctx.user.created_at,
"last_seen": ctx.user.last_seen,
"visits": ctx.user.visits,
"credentials": [
{
"credential": c.uuid,
"aaguid": c.aaguid,
"created_at": c.created_at,
"last_used": c.last_used,
"last_verified": c.last_verified,
"sign_count": c.sign_count,
"is_current_session": session_record.credential == c.uuid,
}
for c in credentials
],
"aaguid_info": aaguid.filter(c.aaguid for c in credentials),
"sessions": [
ApiSession.from_db(
s,
current_key=auth,
normalized_host=normalized_host,
expires_delta=EXPIRES,
)
for s in user.sessions
],
}
sessions = [
ApiUserSession.from_db(
s,
current_key=auth,
normalized_host=normalized_host,
expires_delta=EXPIRES,
)
for s in user.sessions
]
return ApiUserDetail(
user=ApiUser.from_db(user),
credentials={c.uuid: c for c in user.credentials},
aaguid_info={
k: ApiAaguidInfo(**v)
for k, v in aaguid.filter(c.aaguid for c in user.credentials).items()
},
sessions=sessions,
permissions={p.uuid: ApiPermission.from_db(p) for p in ctx.permissions}
if ctx
else {},
)