OAuth2 OpenID Connect provider support, API and DB refactoring (#3)

Allows Paskia to authenticate the user to a client site.
- User friendly client registration flow on the admin app
- Redirect-based authentication flow (per spec)
- Backchannel logout both ways to keep sessions synchronized
- Groups integrated with Paskia's permission system
- Adds email, preferred username and telephone fields on user profile
- All new user basic info layout to show the new information, better looks
- API and DB structures redesigned
- Various unrelated fixes to theming and layout
This commit is contained in:
LeoVasanko committed 2026-02-18 02:40:27 +00:00
1 parent 557ffaa0cd
commit 68dccc1378
71 files changed
+3706 -805

No files matched your search

+35 -5
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue'
import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
import { useAuthStore } from '@/stores/auth'
const props = defineProps({
dialog: Object,
@@ -9,10 +10,20 @@ const props = defineProps({
settings: Object
})
const emit = defineEmits(['submitDialog', 'closeDialog'])
const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient'])
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
const NO_SUBMIT_TYPES = new Set([])
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
// Copy-to-clipboard helper
const authStore = useAuthStore()
function copyText(value, label) {
navigator.clipboard.writeText(value).then(() => {
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
})
}
</script>
<template>
@@ -25,6 +36,7 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
<template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
<template v-else-if="dialog.type==='confirm'">Confirm</template>
</h3>
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
@@ -76,19 +88,19 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
<input ref="displayNameInput" v-model="dialog.data.display_name" required />
</label>
<label>Permission Scope
<input v-model="dialog.data.scope" :placeholder="dialog.type === 'perm-create' ? 'yourapp:permission' : dialog.data.permission.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
<input v-model="dialog.data.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
</label>
<p class="small muted">E.g. yourapp:reports. Changing the scope name may break deployed applications.</p>
<label>Domain Scope
<input v-model="dialog.data.domain" placeholder="e.g. app.example.com" data-form-type="other" />
<input v-model="dialog.data.domain" data-form-type="other" />
</label>
<p class="small muted">If set, this permission is effective only on the specified domain, which can be {{ rpId }} or its subdomain.</p>
<p class="small muted">A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
</template>
<template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p>
</template>
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions">
<div v-if="!NAME_EDIT_TYPES.has(dialog.type) && !NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
<button
type="button"
class="btn-secondary"
@@ -105,10 +117,28 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
</button>
</div>
<div v-else-if="NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
<button
type="button"
class="btn-primary"
@click="$emit('closeDialog')"
>
Close
</button>
</div>
</form>
</Modal>
</template>
<style scoped>
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
.oidc-divider { border: none; border-top: 1px solid var(--color-border); margin: var(--space-sm) 0; }
.oidc-dl { display: grid; grid-template-columns: auto 1fr; gap: 0.2rem 1rem; align-items: baseline; margin: 0; }
.oidc-dl dt { font-size: 0.85rem; color: var(--color-text-muted); white-space: nowrap; }
.oidc-dl dd { margin: 0; cursor: pointer; overflow: hidden; }
.oidc-dl output { font-family: var(--font-mono, monospace); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.oidc-reset-row { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; }
.oidc-groups { cursor: default; }
.oidc-group { cursor: pointer; }
.oidc-group output { white-space: normal; word-break: break-all; }
</style>
+283
View File
@@ -0,0 +1,283 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { getDirection, navigateButtonRow } from '@/utils/keynav'
import { useAuthStore } from '@/stores/auth'
const props = defineProps({
client: Object,
permissions: Array,
isNew: { type: Boolean, default: false },
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['save', 'cancel', 'delete', 'resetSecret', 'createPermission', 'navigateOut'])
const authStore = useAuthStore()
const headerRef = ref(null)
// Helper function to build URLs
function authSitePath(path) {
const url = new URL(authStore.settings.auth_site_url)
url.pathname = path
return url.toString()
}
// Local form state
const name = ref('')
const redirectUris = ref('')
const clientSecret = ref(null)
// Computed
const clientId = computed(() => props.client?.client_id || props.client?.uuid || '')
const discoveryUrl = computed(() => authSitePath('/.well-known/openid-configuration'))
const iconUrl = computed(() => authSitePath('/favicon.ico'))
// Groups (permissions) scoped to this client
const clientGroups = computed(() => {
if (!props.client || !props.permissions) return []
const clientUuid = props.client.uuid || props.client.client_id
return props.permissions.filter(p => p.domain === clientUuid).sort((a, b) => a.scope.localeCompare(b.scope))
})
// Initialize form data from props
watch(() => props.client, (c) => {
if (c) {
name.value = c.name || ''
redirectUris.value = Array.isArray(c.redirect_uris) ? c.redirect_uris.join('\n') : (c.redirect_uris || '')
clientSecret.value = c.client_secret || null
}
}, { immediate: true })
// Copy-to-clipboard helper
function copyText(value, label) {
navigator.clipboard.writeText(value).then(() => {
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
})
}
function handleResetSecret() {
emit('resetSecret', clientId.value)
}
// When parent resets secret, update local state
watch(() => props.client?.client_secret, (newSecret) => {
if (newSecret) {
clientSecret.value = newSecret
}
})
function handleSave() {
const trimmedName = name.value.trim()
if (!trimmedName) {
authStore.showMessage('Client name is required', 'error')
return
}
const uris = redirectUris.value.trim()
const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : []
emit('save', {
client_id: clientId.value,
client_secret: clientSecret.value,
name: trimmedName,
redirect_uris,
isNew: props.isNew
})
}
function handleDelete() {
emit('delete', props.client)
}
function handleCreatePermission() {
emit('createPermission', clientId.value)
}
function handleCancel() {
emit('cancel')
}
// Keyboard navigation
function handleHeaderKeydown(event) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(headerRef.value, event.target, direction, { itemSelector: 'button, a' })
} else if (direction === 'up') {
emit('navigateOut', 'up')
}
}
function focusFirstElement() {
const firstFocusable = headerRef.value?.querySelector('button, a, input')
if (firstFocusable) firstFocusable.focus()
}
defineExpose({ focusFirstElement })
</script>
<template>
<div class="oidc-detail">
<form @submit.prevent="handleSave" class="oidc-form">
<!-- Client credentials section -->
<section class="section-block">
<div class="section-header">
<h2>Client Configuration</h2>
<p class="section-description">Configure these values in the client application.</p>
</div>
<div class="section-body">
<dl class="oidc-dl">
<dt>Authentication Name</dt>
<dd>
<output @click="copyText(authStore.settings.rp_name, 'Authentication Name')" title="Click to copy">{{ authStore.settings.rp_name }}</output>
<span class="small muted"> (Login With, may affect URLs – optional)</span>
</dd>
<dt>Client ID</dt>
<dd><output @click="copyText(clientId, 'Client ID')" title="Click to copy">{{ clientId }}</output></dd>
<dt>Client Secret <button v-if="!clientSecret" type="button" class="icon-btn" @click="handleResetSecret" title="Revoke and re-generate secret">🔄</button></dt>
<dd>
<output v-if="clientSecret" @click="copyText(clientSecret, 'Client Secret')" title="Click to copy">{{ clientSecret }}</output>
<span v-else class="small muted">(only stored in hashed form)</span>
</dd>
<dt>Auto Discovery URL</dt>
<dd><output @click="copyText(discoveryUrl, 'OpenID Connect Auto Discovery URL')" title="Click to copy">{{ discoveryUrl }}</output></dd>
<dt>Icon URL</dt>
<dd>
<output @click="copyText(iconUrl, 'Icon URL')" title="Click to copy">{{ iconUrl }}</output>
<span class="small muted"> (optional)</span>
</dd>
<template v-if="clientGroups.length">
<dt>Groups Claim Name</dt>
<dd>
<output @click="copyText('groups', 'Groups Claim Name')" title="Click to copy">groups</output>
</dd>
</template>
<dt>Groups <button type="button" class="icon-btn" @click="handleCreatePermission" title="Add permission scoped to this client">➕</button></dt>
<dd class="oidc-groups">
<template v-if="clientGroups.length">
<output v-for="group in clientGroups" :key="group.uuid" class="oidc-group" @click="copyText(group.scope, 'Group Value')" :title="group.display_name">{{ group.scope }}</output>
</template>
<span v-else class="small muted">(no permissions defined)</span>
</dd>
</dl>
<span class="warning-text">
<strong v-if="clientSecret">⚠️ {{ isNew ? 'Save the secret now — it cannot be retrieved later.' : 'Saving will prevent access with the old secret.' }}</strong>
<span v-else>ℹ️ The client may use groups to check for required permissions.</span>
</span>
</div>
</section>
<!-- Editable fields -->
<section class="section-block">
<div class="section-header">
<h2>Paskia Configuration</h2>
</div>
<div class="section-body">
<label>Client Name
<input v-model="name" required />
</label>
<label>Redirect URIs
<p class="small muted">This should be provided by the client application.</p>
<textarea v-model="redirectUris" placeholder="(autodiscover one on first use)" rows="3"></textarea>
</label>
</div>
</section>
<!-- Actions -->
<div class="oidc-actions">
<button type="button" class="btn-secondary" @click="handleCancel">Cancel</button>
<button v-if="!isNew" type="button" class="btn-danger" @click="handleDelete">Delete Client</button>
<button type="submit" class="btn-primary">Save</button>
</div>
</form>
</div>
</template>
<style scoped>
.oidc-detail {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.oidc-header {
margin-bottom: 0;
}
.oidc-header h2 {
margin: 0;
}
.oidc-form {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.oidc-dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.3rem 1rem;
align-items: baseline;
margin: var(--space-sm) 0;
}
.oidc-dl dt {
font-size: 0.85rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.oidc-dl dd {
margin: 0;
overflow: hidden;
display: flex;
align-items: baseline;
gap: 0.5em;
}
.oidc-dl output {
font-family: var(--font-mono, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.warning-text {
display: block;
font-size: 0.9rem;
min-height: 1.4em;
}
.oidc-group { display: block; }
.oidc-group { white-space: normal; word-break: break-all; }
.section-body label {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-weight: 500;
}
.oidc-actions {
display: flex;
gap: var(--space-sm);
justify-content: flex-end;
margin-top: var(--space-md);
}
</style>
+23 -24
View File
@@ -16,16 +16,22 @@ const permMatrixRef = ref(null)
const rolesGridRef = ref(null)
const sortedRoles = computed(() => {
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)
})
// o.roles is dict[UUID, Role], convert to array for sorting with uuid added
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 org's grantable permissions as full permission objects (with UUIDs)
const orgPermissions = computed(() => {
// props.selectedOrg.permissions is dict[UUID, Permission]
const uuidSet = new Set(Object.keys(props.selectedOrg.permissions || {}))
return props.permissions.filter(p => uuidSet.has(p.uuid))
})
// Get users for a role as sorted array of { uuid, ...user }
@@ -44,19 +50,12 @@ 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(() => {
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(roleUuid, role, pid, checked) {
emit('toggleRolePermission', roleUuid, role, pid, checked)
function toggleRolePermission(role, pid, checked) {
emit('toggleRolePermission', role, pid, checked)
}
// Handle org title header keynav
@@ -102,7 +101,7 @@ function handleMatrixKeydown(event) {
// Calculate grid dimensions
const cols = sortedRoles.value.length
const rows = props.selectedOrg.permissions.length
const rows = Object.keys(props.selectedOrg.permissions).length
const currentRow = Math.floor(currentIndex / cols)
const currentCol = currentIndex % cols
@@ -238,7 +237,7 @@ function handleRoleHeaderKeydown(event, roleIndex) {
if (checkboxes?.length) {
// Focus the checkbox in the corresponding column
const cols = sortedRoles.value.length
const rows = props.selectedOrg.permissions.length
const rows = Object.keys(props.selectedOrg.permissions).length
const targetIndex = (rows - 1) * cols + roleIndex
if (checkboxes[targetIndex]) checkboxes[targetIndex].focus()
else checkboxes[checkboxes.length - 1].focus()
@@ -337,7 +336,7 @@ defineExpose({ focusFirstElement })
<input
type="checkbox"
:checked="p.uuid in (r.permissions || {})"
@change="e => toggleRolePermission(r.uuid, r, p.uuid, e.target.checked)"
@change="e => toggleRolePermission(r, p.uuid, e.target.checked)"
/>
</div>
<div class="matrix-cell add-role-cell" />
@@ -352,13 +351,13 @@ defineExpose({ focusFirstElement })
:key="r.uuid"
class="role-column"
@dragover="$emit('onRoleDragOver', $event)"
@drop="e => $emit('onRoleDrop', e, selectedOrg, r.uuid)"
@drop="e => $emit('onRoleDrop', e, selectedOrg, r)"
>
<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="roleUserCount(r.uuid) === 0" @click="$emit('deleteRole', r.uuid, 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)" 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>
+86 -2
View File
@@ -1,16 +1,18 @@
<script setup>
import { computed, ref } from 'vue'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
import { formatDate } from '@/utils/helpers'
const props = defineProps({
info: Object,
orgs: Array,
permissions: Array,
oidcClients: Array,
permissionSummary: Object,
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'navigateOut'])
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'navigateOut'])
// Template refs for navigation
const orgSection = ref(null)
@@ -19,11 +21,41 @@ const orgTableRef = ref(null)
const permMatrixRef = ref(null)
const permActionsRef = ref(null)
const permTableRef = ref(null)
const oidcActionsRef = ref(null)
const oidcTableRef = ref(null)
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
const nameCompare = a.org.display_name.localeCompare(b.org.display_name)
return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid)
}))
// Map OIDC client UUIDs to display names for permission domain column
const oidcClientNames = computed(() => {
const map = {}
for (const c of props.oidcClients || []) map[c.uuid] = c.name
return map
})
function domainDisplay(domain) {
if (!domain) return '—'
return oidcClientNames.value[domain] || domain
}
// Map OIDC client UUIDs to their group permissions (sorted by scope)
const clientGroups = computed(() => {
const map = {}
for (const p of props.permissions || []) {
if (p.domain) {
if (!map[p.domain]) map[p.domain] = []
map[p.domain].push(p)
}
}
// Sort each group array by scope
for (const key in map) {
map[key].sort((a, b) => a.scope.localeCompare(b.scope))
}
return map
})
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope)))
// Derive admin status from permissions (info contains ctx from validate response)
@@ -35,6 +67,7 @@ function permissionDisplayName(scope) {
}
function getRoleNames(org) {
// org.roles is dict[UUID, Role]
return Object.values(org.roles)
.slice()
.sort((a, b) => a.display_name.localeCompare(b.display_name))
@@ -343,7 +376,7 @@ defineExpose({ focusFirstElement })
<span class="id-text">{{ p.scope }}</span>
</div>
</td>
<td class="perm-domain">{{ p.domain || '—' }}</td>
<td class="perm-domain">{{ domainDisplay(p.domain) }}</td>
<td class="perm-members center">{{ permissionSummary[p.uuid]?.userCount || 0 }}</td>
<td class="perm-actions center">
<button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission">❌</button>
@@ -352,6 +385,52 @@ defineExpose({ focusFirstElement })
</tbody>
</table>
</div>
<div v-if="isMasterAdmin" class="oidc-clients-section">
<div class="section-header">
<h2>OAuth2 / OpenID Connect</h2>
<p class="section-description">
Allow external websites and applications to securely authenticate users through this system.
The clients are remote sites or applications that we allow to use Paskia for Single Sign-On.
</p>
</div>
<div ref="oidcActionsRef">
<button @click="$emit('createOidcClient')">+ Add Site</button>
</div>
<table class="org-table" ref="oidcTableRef">
<thead>
<tr>
<th scope="col">Client</th>
<th scope="col">Groups</th>
<th scope="col" class="center">Sessions</th>
<th scope="col" class="center">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="!oidcClients || oidcClients.length === 0">
<td colspan="4" class="center muted">No OIDC clients configured</td>
</tr>
<tr v-for="client in oidcClients" :key="client.uuid">
<td class="perm-name-cell">
<div class="perm-title">
<a :href="'#oidc:' + client.uuid" @click.prevent="$emit('openOidcClient', client)">{{ client.name }}</a>
</div>
<div class="perm-id-info">
<span class="id-text">{{ client.uuid }}</span>
</div>
</td>
<td class="client-groups">
<span v-if="clientGroups[client.uuid]?.length">{{ clientGroups[client.uuid].map(g => g.scope).join(' ') }}</span>
<span v-else class="muted">—</span>
</td>
<td class="center">{{ client.active_sessions || 0 }}</td>
<td class="center">
<button @click="$emit('deleteOidcClient', client)" class="icon-btn delete-icon" aria-label="Delete OIDC client" title="Delete OIDC client">❌</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
@@ -371,4 +450,9 @@ defineExpose({ focusFirstElement })
.edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; }
.edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); }
.perm-actions { text-align: center; }
/* OIDC Clients Section */
.oidc-clients-section { margin-bottom: var(--space-xl); margin-top: var(--space-2xl); }
.oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); }
</style>
+42 -18
View File
@@ -20,9 +20,15 @@ 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)
// Convert credentials dict to array with uuid attached as 'credential'
const credentials = computed(() =>
Object.entries(props.userDetail?.credentials || {}).map(([uuid, c]) => ({ ...c, credential: uuid }))
)
// Template refs for navigation
const userInfoRef = ref(null)
const regActionsRef = ref(null)
@@ -44,7 +50,7 @@ function handleEditName() {
async function handleDelete(credential) {
try {
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.uuid}`, { method: 'DELETE' })
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
if (data.status === 'ok') {
emit('onUserNameSaved') // Reuse to refresh user detail
} else {
@@ -56,15 +62,29 @@ async function handleDelete(credential) {
}
async function handleTerminateSession(session) {
const credentialUuid = session?.credential
if (!credentialUuid) return
const sessionKey = session?.key
if (!sessionKey) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
try {
await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credentialUuid}`, { method: 'DELETE' })
emit('refreshUserDetail')
authStore.showMessage('Credential deleted', 'success', 2500)
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/sessions/${sessionKey}`, { 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')
}
} catch (err) {
console.error('Delete credential error', err)
authStore.showMessage(err.message || 'Failed to delete credential', 'error')
console.error('Terminate session error', err)
authStore.showMessage(err.message || 'Failed to terminate session', 'error')
} finally {
const next = { ...terminatingSessions.value }
delete next[sessionKey]
terminatingSessions.value = next
}
}
@@ -165,23 +185,26 @@ defineExpose({ focusFirstElement })
<div ref="userInfoRef" @keydown="handleUserInfoKeydown">
<UserBasicInfo
v-if="userDetail && !userDetail.error"
:name="userDetail.user.display_name"
:name="userDetail.user.display_name || selectedUser.display_name"
:visits="userDetail.user.visits"
:created-at="userDetail.user.created_at"
:last-seen="userDetail.user.last_seen"
:email="userDetail.user.email"
:telephone="userDetail.user.telephone"
:loading="loading"
:org-display-name="selectedOrg?.org?.display_name"
:role-name="selectedUser?.role_display_name"
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/display-name`"
:org-display-name="userDetail.org.display_name"
:role-name="userDetail.role.display_name"
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
@saved="$emit('onUserNameSaved')"
@edit-name="handleEditName"
@edit="handleEditName"
>
<div class="admin-actions">
<button
class="btn-primary"
@click="$emit('generateUserRegistrationLink', selectedUser)"
:disabled="loading"
>{{ Object.keys(userDetail?.credentials || {}).length ? 'Recovery Link' : 'Registration Link' }}</button>
title="Generate a one-time link for this user"
>{{ userDetail?.credentials && Object.keys(userDetail.credentials).length > 0 ? 'Recovery Link' : 'Registration Link' }}</button>
<button
class="btn-danger"
@click="handleDeleteUser"
@@ -200,11 +223,11 @@ defineExpose({ focusFirstElement })
<div class="section-body">
<CredentialList
ref="credentialListRef"
:credentials="Object.entries(userDetail.credentials).map(([uuid, c]) => ({ ...c, uuid })).sort((a, b) => new Date(a.created_at) - new Date(b.created_at))"
:credentials="credentials"
:aaguid-info="userDetail.aaguid_info"
:allow-delete="true"
:hovered-credential-uuid="hoveredCredentialUuid"
:hovered-session-credential-uuid="hoveredSession?.credential || null"
:hovered-session-credential-uuid="hoveredSession?.credential"
:navigation-disabled="hasActiveModal"
@delete="handleDelete"
@credential-hover="hoveredCredentialUuid = $event"
@@ -214,7 +237,8 @@ defineExpose({ focusFirstElement })
</section>
<SessionList
ref="sessionListRef"
:sessions="userDetail.sessions"
:sessions="userDetail.sessions || {}"
:terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal"
:empty-message="'This user has no active sessions.'"
@@ -230,7 +254,7 @@ defineExpose({ focusFirstElement })
<RegistrationLinkModal
v-if="showRegModal"
:endpoint="`/auth/api/admin/users/${selectedUser.uuid}/create-link`"
:user-name="userDetail?.user.display_name || selectedUser.display_name"
:user-name="userDetail?.display_name || selectedUser.display_name"
@close="$emit('closeRegModal')"
@copied="onLinkCopied"
/>