Files
paskia/frontend/src/admin/AdminUserDetail.vue
T
LeoVasanko 95c163e37a Add profile picture support
- backend avatar storage and OIDC picture claims
- profile and admin UI components
- admin org cards, tests, and docs
2026-05-21 23:57:48 +00:00

311 lines
11 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, computed } from 'vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import CredentialList from '@/components/CredentialList.vue'
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import SessionList from '@/components/SessionList.vue'
import { useAuthStore } from '@/stores/auth'
import { apiJson } from 'paskia'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
const props = defineProps({
selectedUser: Object,
userDetail: Object,
selectedOrg: Object,
loading: Boolean,
showRegModal: Boolean,
navigationDisabled: { type: Boolean, default: false }
})
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)
const showPictureDialog = ref(false)
const avatarRenderVersion = ref(0)
// 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)
const credentialListRef = ref(null)
const sessionListRef = ref(null)
const backButtonRef = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => props.showRegModal)
function onLinkCopied() {
authStore.showMessage(`📋 Link copied! Send it to ${props.selectedUser.display_name}.`)
emit('closeRegModal')
}
function handleEditName() {
emit('editUserName', props.selectedUser)
}
function openPictureDialog() {
if (!props.userDetail || props.userDetail.error) return
showPictureDialog.value = true
}
function closePictureDialog() {
showPictureDialog.value = false
}
function handlePictureUpdated() {
avatarRenderVersion.value += 1
emit('refreshUserDetail')
}
async function handleDelete(credential) {
try {
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 {
console.error('Failed to delete credential', data)
}
} catch (err) {
console.error('Delete credential error', err)
}
}
async function handleTerminateSession(session) {
const sessionKey = session?.key
if (!sessionKey) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
try {
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('Terminate session error', err)
authStore.showMessage(err.message || 'Failed to terminate session', 'error')
} finally {
const next = { ...terminatingSessions.value }
delete next[sessionKey]
terminatingSessions.value = next
}
}
async function handleDeleteUser() {
emit('deleteUser')
}
// Handle user info section keynav
function handleUserInfoKeydown(event) {
if (hasActiveModal.value || props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.user-picture-btn, .mini-btn' })
} else if (direction === 'up') {
emit('navigateOut', 'up')
} else if (direction === 'down') {
// Move to registration actions
focusPreferred(regActionsRef.value, { itemSelector: 'button' })
}
}
// Handle registration actions keynav
function handleRegActionsKeydown(event) {
if (hasActiveModal.value || props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(regActionsRef.value, event.target, direction, { itemSelector: 'button' })
} else if (direction === 'up') {
// Move to user info edit button
focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
} else if (direction === 'down') {
// Move to credential list
credentialListRef.value?.$el?.focus()
}
}
// Handle credential list navigate out
function handleCredentialNavigateOut(direction) {
if (hasActiveModal.value || props.navigationDisabled) return
if (direction === 'up') {
focusPreferred(regActionsRef.value, { itemSelector: 'button' })
} else if (direction === 'down') {
// Move to session list
focusAtIndex(sessionListRef.value?.$el, 0, { itemSelector: '.session-group' })
}
}
// Handle session list navigate out
function handleSessionNavigateOut(direction) {
if (hasActiveModal.value || props.navigationDisabled) return
if (direction === 'up') {
// Move to credential list
credentialListRef.value?.$el?.focus()
} else if (direction === 'down') {
// Move to back button
const backBtn = backButtonRef.value?.querySelector('button')
if (backBtn) backBtn.focus()
}
}
// Handle back button keynav
function handleBackButtonKeydown(event) {
if (hasActiveModal.value || props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'up') {
// Move to session list
focusAtIndex(sessionListRef.value?.$el, -1, { itemSelector: '.session-group' })
}
}
// Focus helper for external navigation
function focusFirstElement() {
focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
}
defineExpose({ focusFirstElement })
const currentPictureEndpoint = computed(() => {
if (!props.selectedUser?.uuid) return null
return `/auth/api/user/${props.selectedUser.uuid}/profile.webp`
})
const adminPictureTitle = computed(() => {
const username = props.userDetail?.user?.preferred_username || props.selectedUser?.preferred_username
const displayName = props.userDetail?.user?.display_name || props.selectedUser?.display_name
const label = username || displayName || 'User'
return `Profile Picture for ${label}`
})
</script>
<template>
<div class="user-detail">
<div ref="userInfoRef" @keydown="handleUserInfoKeydown">
<UserBasicInfo
v-if="userDetail && !userDetail.error"
:name="userDetail.user.display_name || selectedUser.display_name"
:avatar-url="userDetail.user.avatar_url"
:avatar-render-version="avatarRenderVersion"
avatar-clickable
: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="userDetail.org.display_name"
:role-name="userDetail.role.display_name"
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
@saved="$emit('onUserNameSaved')"
@avatar-click="openPictureDialog"
@edit="handleEditName"
>
<div class="admin-actions">
<button
class="btn-primary"
@click="$emit('generateUserRegistrationLink', selectedUser)"
:disabled="loading"
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"
:disabled="loading"
title="Delete this user"
>Delete User</button>
</div>
</UserBasicInfo>
</div>
<div v-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
<template v-if="userDetail && !userDetail.error">
<section class="section-block" data-section="registered-passkeys">
<div class="section-header">
<h2>Registered Passkeys</h2>
</div>
<div class="section-body">
<CredentialList
ref="credentialListRef"
:credentials="credentials"
:aaguid-info="userDetail.aaguid_info"
:allow-delete="true"
:hovered-credential-uuid="hoveredCredentialUuid"
:hovered-session-credential-uuid="hoveredSession?.credential"
:navigation-disabled="hasActiveModal"
@delete="handleDelete"
@credential-hover="hoveredCredentialUuid = $event"
@navigate-out="handleCredentialNavigateOut"
/>
</div>
</section>
<SessionList
ref="sessionListRef"
:sessions="userDetail.sessions || {}"
:terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal"
:empty-message="'This user has no active sessions.'"
:section-description="'View and manage the active sessions for this user.'"
@terminate="handleTerminateSession"
@session-hover="hoveredSession = $event"
@navigate-out="handleSessionNavigateOut"
/>
</template>
<div class="actions ancillary-actions" ref="backButtonRef" @keydown="handleBackButtonKeydown">
<button v-if="selectedOrg" @click="$emit('openOrg', selectedOrg)" class="icon-btn" title="Back to Org"></button>
</div>
<RegistrationLinkModal
v-if="showRegModal"
:endpoint="`/auth/api/admin/users/${selectedUser.uuid}/create-link`"
:user-name="userDetail?.display_name || selectedUser.display_name"
@close="$emit('closeRegModal')"
@copied="onLinkCopied"
/>
<ProfilePictureEditorModal
v-if="showPictureDialog && currentPictureEndpoint"
:endpoint="currentPictureEndpoint"
:picture-url="userDetail?.user?.avatar_url"
:render-version="avatarRenderVersion"
:title="adminPictureTitle"
@close="closePictureDialog"
@updated="handlePictureUpdated"
/>
</div>
</template>
<style scoped>
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
.admin-actions { display: flex; gap: 0.5rem; }
.ancillary-actions { margin-top: -0.5rem; }
</style>