API cleanup, using msgspec structs rather than raw responses. Admin app cleanup, better breadcrumbs.
This commit is contained in:
+14
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -235,4 +235,4 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -102,6 +102,7 @@ def bootstrap(
|
||||
created_at=now,
|
||||
last_seen=None,
|
||||
visits=0,
|
||||
theme="",
|
||||
)
|
||||
admin_user.uuid = user_uuid
|
||||
admin_user.store()
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user