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