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:
2026-02-18 02:40:27 +00:00
parent 557ffaa0cd
commit 68dccc1378
71 changed files with 3706 additions and 805 deletions
+9 -9
View File
@@ -5,16 +5,16 @@
<template v-else>
<div
v-for="credential in credentials"
:key="credential.uuid"
:key="credential.credential"
:class="['credential-item', {
'current-session': credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid,
'is-hovered': hoveredCredentialUuid === credential.uuid,
'is-linked-session': hoveredSessionCredentialUuid === credential.uuid
'is-hovered': hoveredCredentialUuid === credential.credential,
'is-linked-session': hoveredSessionCredentialUuid === credential.credential
}]"
tabindex="-1"
@mousedown.prevent
@click.capture="handleCardClick"
@focusin="handleCredentialFocus(credential.uuid)"
@focusin="handleCredentialFocus(credential.credential)"
@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.uuid" class="badge badge-current">Selected</span>
<span v-else-if="hoveredSessionCredentialUuid === credential.uuid" class="badge badge-current">Linked</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>
<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 = document.documentElement.classList.contains('dark')
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
return info[iconKey] || info.icon || null
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
// Fall back to icon if icon_dark is not available
return (isDarkMode && info.icon_dark) || info.icon || null
}
</script>
+5 -3
View File
@@ -13,6 +13,8 @@
:visits="authStore.userInfo?.visits || 0"
:created-at="authStore.userInfo?.created_at"
:last-seen="authStore.userInfo?.last_seen"
:email="ctx.user.email"
:telephone="ctx.user.telephone"
:org-display-name="orgDisplayName"
:role-name="roleDisplayName"
:can-edit="false"
@@ -78,9 +80,9 @@ const currentHost = window.location.host
const userInfoSection = ref(null)
const buttonRow = ref(null)
const ctx = computed(() => authStore.userInfo?.ctx || null)
const orgDisplayName = computed(() => ctx.value?.org.display_name ?? '')
const roleDisplayName = computed(() => ctx.value?.role.display_name ?? '')
const ctx = computed(() => authStore.userInfo || null)
const orgDisplayName = computed(() => ctx.value?.org?.display_name ?? '')
const roleDisplayName = computed(() => ctx.value?.role?.display_name ?? '')
const headingTitle = computed(() => {
const service = authStore.settings?.rp_name
+27 -44
View File
@@ -1,12 +1,15 @@
<template>
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
<slot />
</dialog>
<div class="dialog-overlay" @click="$emit('close')">
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
<slot />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
const props = defineProps({
// Optional: provide a fallback element to focus if original element is gone
@@ -17,7 +20,7 @@ const props = defineProps({
focusSiblingSelector: { type: String, default: '' }
})
defineEmits(['close'])
const emit = defineEmits(['close'])
// Dialog element reference
const dialog = ref(null)
@@ -76,6 +79,13 @@ const restoreFocus = () => {
}
const handleDialogKeydown = (event) => {
// ESC to close (previously handled by <dialog> natively)
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
const direction = getDirection(event)
if (!direction) return
@@ -111,11 +121,11 @@ onMounted(() => {
// Save currently focused element before modal takes focus
previouslyFocusedElement.value = document.activeElement
// Show the dialog as a modal
holdGlobalBackdrop()
// Focus the most appropriate element
nextTick(() => {
if (dialog.value) {
dialog.value.showModal()
// Autofocus the most appropriate element:
// - For form dialogs (rename, edit): focus first input and select text
// - For other dialogs: focus primary button (or fallback)
@@ -131,14 +141,16 @@ onMounted(() => {
})
onUnmounted(() => {
releaseGlobalBackdrop()
// Restore focus when modal closes
restoreFocus()
})
</script>
<style scoped>
dialog {
background: var(--color-surface);
.modal-panel {
background: var(--color-dialog);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
@@ -147,65 +159,36 @@ dialog {
width: min(500px, 90vw);
max-height: 90vh;
overflow-y: auto;
position: fixed;
inset: 0;
margin: auto;
height: fit-content;
}
dialog::backdrop {
background: transparent;
backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
}
dialog :deep(.modal-title),
dialog :deep(h3) {
.modal-panel :deep(.modal-title),
.modal-panel :deep(h3) {
margin: 0 0 var(--space-md);
font-size: 1.25rem;
font-weight: 600;
color: var(--color-heading);
}
dialog :deep(form) {
.modal-panel :deep(form) {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
dialog :deep(.modal-form) {
.modal-panel :deep(.modal-form) {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
dialog :deep(.modal-form label) {
.modal-panel :deep(.modal-form label) {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-weight: 500;
}
dialog :deep(.modal-form input),
dialog :deep(.modal-form textarea) {
padding: var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 1rem;
line-height: 1.4;
min-height: 2.5rem;
}
dialog :deep(.modal-form input:focus),
dialog :deep(.modal-form textarea:focus) {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px #c7d2fe;
}
dialog :deep(.modal-actions) {
.modal-panel :deep(.modal-actions) {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
+92 -50
View File
@@ -6,7 +6,7 @@
</div>
<header class="view-header">
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p>
<p class="view-lede">Account dashboard to manage your profile and authentications.</p>
</header>
</div>
@@ -15,13 +15,18 @@
v-if="authStore.userInfo?.user"
ref="userBasicInfo"
:name="authStore.userInfo.user.display_name"
:email="authStore.userInfo.user.email"
:preferred_username="authStore.userInfo.user.preferred_username"
:telephone="authStore.userInfo.user.telephone"
: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"
:org-display-name="authStore.ctx?.org.display_name"
:role-name="authStore.ctx?.role.display_name"
update-endpoint="/auth/api/user/info"
@saved="authStore.loadUserInfo()"
@edit-name="openNameDialog"
@edit="openEditDialog"
@keydown="handleUserInfoKeydown"
>
<div class="remote-auth-inline">
@@ -35,7 +40,7 @@
@device-info-visible="showDeviceInfo = $event"
/>
</div>
<p class="remote-auth-description">Provided by another device requesting remote auth.</p>
<p class="remote-auth-description">Login from another device</p>
</UserBasicInfo>
</section>
@@ -51,7 +56,7 @@
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
:loading="authStore.isLoading"
:hovered-credential-uuid="hoveredCredentialUuid"
:hovered-session-credential-uuid="hoveredSessionCredential"
:hovered-session-credential-uuid="hoveredSession?.credential"
:navigation-disabled="hasActiveModal"
allow-delete
@delete="handleDelete"
@@ -68,11 +73,12 @@
<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="handleSessionHover"
@session-hover="hoveredSession = $event"
@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."
/>
@@ -100,15 +106,28 @@
</div>
</section>
<Modal v-if="showNameDialog" @close="showNameDialog = false">
<h3>Edit Display Name</h3>
<form @submit.prevent="saveName" class="modal-form">
<NameEditForm
label="Display Name"
v-model="newName"
:busy="saving"
@cancel="showNameDialog = false"
/>
<Modal v-if="showEditDialog" @close="showEditDialog = false">
<h3>Edit Profile</h3>
<form @submit.prevent="saveProfile" class="modal-form">
<div class="profile-edit-form">
<label for="edit-display-name">Display Name
<input id="edit-display-name" type="text" v-model="editName" :disabled="saving" required />
</label>
<label for="edit-email">Email
<input id="edit-email" type="email" v-model="editEmail" :disabled="saving" />
</label>
<label for="edit-username">Preferred Username
<input id="edit-username" type="text" v-model="editUsername" :disabled="saving" placeholder="username" />
</label>
<label for="edit-telephone">Telephone
<input id="edit-telephone" type="tel" v-model="editTelephone" :disabled="saving" />
</label>
<div v-if="editError" class="error small">{{ editError }}</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" @click="showEditDialog = false" :disabled="saving">Cancel</button>
<button type="submit" class="btn-primary" :disabled="saving" data-nav-primary>Save</button>
</div>
</div>
</form>
</Modal>
@@ -128,7 +147,6 @@ import CredentialList from '@/components/CredentialList.vue'
import ThemeSelector from '@/components/ThemeSelector.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
import SessionList from '@/components/SessionList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import RemoteAuthPermit from '@/components/RemoteAuthPermit.vue'
@@ -141,13 +159,16 @@ import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@
const authStore = useAuthStore()
const updateInterval = ref(null)
const showNameDialog = ref(false)
const showEditDialog = ref(false)
const showRegLink = ref(false)
const newName = ref('')
const editName = ref('')
const editEmail = ref('')
const editUsername = ref('')
const editTelephone = ref('')
const saving = ref(false)
const editError = ref('')
const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
const hoveredSessionCredential = ref(null)
const showDeviceInfo = ref(false)
const pairingEntry = ref(null)
const credentialList = ref(null)
@@ -159,9 +180,17 @@ const userBasicInfo = ref(null)
const userInfoSection = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value)
const hasActiveModal = computed(() => showEditDialog.value || showRegLink.value)
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.ctx.user.display_name ?? '' })
watch(showEditDialog, (open) => {
if (!open) return
const user = authStore.userInfo?.user
editName.value = user?.display_name ?? ''
editEmail.value = user?.email ?? ''
editUsername.value = user?.preferred_username ?? ''
editTelephone.value = user?.telephone ?? ''
editError.value = ''
})
onMounted(() => {
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
@@ -169,11 +198,6 @@ 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, () => {
@@ -307,7 +331,7 @@ const handleLogoutButtonKeydown = (event) => {
}
const handleDelete = async (credential) => {
const credentialId = credential?.uuid
const credentialId = credential?.credential
if (!credentialId) return
try {
await authStore.deleteCredential(credentialId)
@@ -317,37 +341,41 @@ 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 sessions = computed(() => authStore.userInfo?.sessions || {})
const currentSessionHost = computed(() => {
const currentSession = sessions.value.find(session => session.is_current)
const currentSession = Object.values(sessions.value).find(session => session.is_current)
return currentSession?.host || 'this host'
})
const terminatingSessions = ref({})
const terminateSession = async (session) => {
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 sessionKey = session?.key
if (!sessionKey) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
try { await authStore.terminateSession(sessionKey) }
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
finally {
const next = { ...terminatingSessions.value }
delete next[sessionKey]
terminatingSessions.value = next
}
}
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
const logout = async () => { await authStore.logout() }
const openNameDialog = () => { newName.value = authStore.userInfo?.user.display_name ?? ''; showNameDialog.value = true }
const openEditDialog = () => { showEditDialog.value = true }
const isAdmin = computed(() => {
const perms = authStore.ctx?.permissions
return perms?.includes('auth:admin') || perms?.includes('auth:org:admin')
})
const hasMultipleSessions = computed(() => sessions.value.length > 1)
const hasMultipleSessions = computed(() => Object.keys(sessions.value).length > 1)
const credentials = computed(() =>
Object.entries(authStore.userInfo?.credentials || {}).map(([uuid, c]) => ({ ...c, credential: uuid }))
)
const useWideLayout = computed(() => {
// Check if any single site has more than 8 sessions
const groups = {}
for (const session of sessions.value) {
for (const session of Object.values(sessions.value)) {
const host = session.host || ''
if (!groups[host]) groups[host] = []
groups[host].push(session)
@@ -361,17 +389,30 @@ const useWideLayout = computed(() => {
})
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()
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
const saveProfile = async () => {
const name = editName.value.trim()
if (!name) { editError.value = 'Name cannot be empty'; return }
const user = authStore.userInfo.user
const emailVal = editEmail.value.trim() || null
const usernameVal = editUsername.value.trim() || null
const telephoneVal = editTelephone.value.trim() || null
try {
editError.value = ''
saving.value = true
await apiJson('/auth/api/user/display-name', { method: 'PATCH', body: { display_name: name } })
showNameDialog.value = false
await authStore.loadUserInfo()
authStore.showMessage('Name updated successfully!', 'success', 3000)
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
finally { saving.value = false }
const body = {}
if (name !== user.display_name) body.display_name = name
if (emailVal !== (user.email || null)) body.email = emailVal
if (usernameVal !== (user.preferred_username || null)) body.preferred_username = usernameVal
if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
if (Object.keys(body).length) {
await apiJson('/auth/api/user/info', { method: 'PATCH', body })
await authStore.loadUserInfo()
authStore.showMessage('Profile updated!', 'success', 3000)
}
showEditDialog.value = false
} catch (e) {
editError.value = e.message || 'Failed to update profile'
} finally { saving.value = false }
}
</script>
@@ -386,4 +427,5 @@ const saveName = async () => {
.remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
.remote-auth-description { font-size: 0.75rem; color: var(--color-text-muted); }
.theme-toggle { position: absolute; top: var(--layout-padding); right: var(--layout-padding); }
.profile-edit-form { display: flex; flex-direction: column; gap: var(--space-md); }
</style>
@@ -1,6 +1,7 @@
<template>
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
<div v-if="linkUrl" class="dialog-overlay" @click="$emit('close')">
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
<div class="reg-header-row">
<h2 id="regTitle" class="reg-title">
📱 <span v-if="userName">{{ tokenType === 'account recovery' ? 'Recovery' : 'Registration' }} for {{ userName }}</span><span v-else>Add Another Device</span>
@@ -14,7 +15,6 @@
</p>
<QRCodeDisplay
v-if="linkUrl"
:url="linkUrl"
:show-link="true"
@copied="onCopied"
@@ -29,14 +29,15 @@
<div class="reg-actions" ref="actionsRow" @keydown="handleActionsKeydown">
<button class="btn-secondary" @click="$emit('close')">Close</button>
</div>
</div>
</div>
</dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
import { apiJson } from 'paskia'
import { apiJson, holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
import { formatDate } from '@/utils/helpers'
import { getDirection } from '@/utils/keynav'
import { useAuthStore } from '@/stores/auth'
@@ -78,16 +79,13 @@ async function generateLink() {
expiresAt.value = data.expires ? new Date(data.expires) : null
tokenType.value = data.token_type || null
// Show the dialog as modal
await nextTick()
if (dialog.value) {
dialog.value.showModal()
holdGlobalBackdrop()
// Focus primary button (or first button if no primary) after content renders
const actions = actionsRow.value
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
target?.focus()
}
// Focus primary button (or first button if no primary) after content renders
await nextTick()
const actions = actionsRow.value
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
target?.focus()
} else {
emit('close')
}
@@ -102,7 +100,12 @@ function onCopied() {
}
const handleDialogKeydown = (event) => {
// ESC is handled automatically by <dialog>
// ESC to close
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
// Handle other key navigation
const direction = getDirection(event)
if (!direction) return
@@ -148,6 +151,7 @@ onMounted(() => {
})
onUnmounted(() => {
if (linkUrl.value) releaseGlobalBackdrop()
// Restore focus when modal closes
const prev = previouslyFocusedElement.value
if (prev && document.body.contains(prev) && !prev.disabled) {
@@ -157,23 +161,6 @@ onUnmounted(() => {
</script>
<style scoped>
dialog {
border: none;
background: transparent;
padding: 0;
max-width: none;
width: fit-content;
height: fit-content;
position: fixed;
inset: 0;
margin: auto;
}
dialog::backdrop {
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.5);
}
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
.icon-btn:hover { opacity: 1; }
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
@@ -771,7 +771,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
.input-wrapper {
position: relative;
display: flex;
width: 280px;
max-width: 100%;
}
@@ -196,7 +196,7 @@ async function startRemoteAuth() {
} else if (msg.status === 'authenticated') {
// Success
completed.value = true
emit('authenticated', { session_token: msg.session_token })
emit('authenticated', { exchange_code: msg.exchange_code })
break
} else if (msg.status === 'denied') {
// Explicitly denied by the authenticating device
+25 -10
View File
@@ -66,7 +66,11 @@ const props = defineProps({
mode: {
type: String,
default: 'login',
validator: (value) => ['login', 'reauth', 'forbidden'].includes(value)
validator: (value) => ['login', 'reauth', 'forbidden', 'oidc'].includes(value)
},
oidcQueryString: {
type: String,
default: null
}
})
@@ -163,7 +167,7 @@ async function authenticateUser() {
loading.value = true
showMessage('Starting authentication…', 'info')
let result
try { result = await passkey.authenticate() } catch (error) {
try { result = await passkey.authenticate(props.oidcQueryString) } catch (error) {
loading.value = false
const message = error?.message || 'Passkey authentication cancelled'
const cancelled = message === 'Passkey authentication cancelled'
@@ -171,7 +175,13 @@ async function authenticateUser() {
emit('auth-error', { message, cancelled })
return
}
try { await setSessionCookie(result) } catch (error) {
// OIDC flow: no session cookie, just emit the redirect_url
if (result.redirect_url) {
loading.value = false
emit('authenticated', result)
return
}
try { await exchangeCode(result) } catch (error) {
loading.value = false
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
@@ -202,13 +212,13 @@ function openProfile() {
if (profileWindow) profileWindow.focus()
}
async function setSessionCookie(result) {
if (!result?.session_token) {
console.error('setSessionCookie called with missing session_token:', result)
throw new Error('Authentication response missing session_token')
async function exchangeCode(result) {
if (!result?.exchange_code) {
console.error('exchangeCode called with missing exchange_code:', result)
throw new Error('Authentication response missing exchange_code')
}
return await fetchJson('/auth/api/set-session', {
method: 'POST', headers: { Authorization: `Bearer ${result.session_token}` }
method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }
})
}
@@ -223,7 +233,7 @@ function switchToLocal() {
async function handleRemoteAuthenticated(result) {
showMessage('Authenticated from another device!', 'success', 2000)
try {
await setSessionCookie(result)
await exchangeCode(result)
} catch (error) {
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
@@ -265,7 +275,12 @@ watch(initializing, (newVal) => {
onMounted(async () => {
await fetchSettings()
await validateSession()
// OIDC mode doesn't depend on session state - skip validation
if (props.mode !== 'oidc') {
await validateSession()
} else {
currentView.value = 'login'
}
initializing.value = false
// Add click handler for inline links
+34 -31
View File
@@ -6,20 +6,21 @@
</div>
<div class="section-body">
<div>
<template v-if="Array.isArray(sessions) && sessions.length">
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, host)">
<template v-if="sessionsArray.length">
<div v-for="(group, key) in groupedSessions" :key="key" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, key)">
<span :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
<span class="session-group-icon">🌐</span>
<a v-if="host" :href="hostUrl(host)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ host }}</a>
<span class="session-group-icon">{{ group.isOIDC ? '🪪' : '🌐' }}</span>
<template v-if="group.isOIDC">{{ group.displayName }}</template>
<a v-else-if="key" :href="hostUrl(key)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ key }}</a>
<template v-else>Unbound host</template>
</span>
<div class="session-list">
<div
v-for="(session, index) in group.sessions"
:key="index"
v-for="session in group.sessions"
:key="session.key"
:class="['session-item', {
'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
'is-hovered': hoveredSession === session,
'is-hovered': hoveredSession?.key === session.key,
'is-linked-credential': hoveredCredentialUuid === session.credential
}]"
tabindex="-1"
@@ -33,13 +34,14 @@
<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 === session" class="badge badge-current">Selected</span>
<span v-else-if="hoveredSession?.key === session.key" 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"
:title="'Delete associated passkey'"
:disabled="isTerminating(session.key)"
:title="isTerminating(session.key) ? 'Terminating...' : 'Terminate session'"
tabindex="-1"
></button>
</div>
@@ -68,9 +70,10 @@ import { hostIP } from '@/utils/helpers'
import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav'
const props = defineProps({
sessions: { type: Array, default: () => [] },
sessions: { type: Object, 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: '' },
@@ -105,6 +108,8 @@ const handleCardClick = (event) => {
}
}
const isTerminating = (sessionKey) => !!props.terminatingSessions[sessionKey]
const handleGroupKeydown = (event, host) => {
const group = event.currentTarget
const sessionList = group.querySelector('.session-list')
@@ -146,7 +151,7 @@ const handleGroupKeydown = (event, host) => {
const handleItemKeydown = (event, session) => {
// Handle delete (always allowed even with modal)
handleDeleteKey(event, () => {
if (!isTerminating(session.id)) emit('terminate', session)
if (!isTerminating(session.key)) emit('terminate', session)
})
if (event.defaultPrevented) return
@@ -205,9 +210,14 @@ const copyIp = async (ip) => {
const displayIp = ip => hostIP(ip) ?? ip
// Convert sessions dict to array with key attached
const sessionsArray = computed(() =>
Object.entries(props.sessions || {}).map(([key, session]) => ({ ...session, key }))
)
const currentHostIP = computed(() => {
if (hoveredIp.value) return hostIP(hoveredIp.value)
const current = props.sessions.find(s => s.is_current)
const current = sessionsArray.value.find(s => s.is_current)
return current ? hostIP(current.ip) : null
})
@@ -215,27 +225,20 @@ const isSameHost = ip => currentHostIP.value && hostIP(ip) === currentHostIP.val
const groupedSessions = computed(() => {
const groups = {}
for (const session of props.sessions) {
const host = session.host || ''
if (!groups[host]) {
groups[host] = { sessions: [], isCurrentSite: false }
}
groups[host].sessions.push(session)
if (session.is_current_host) {
groups[host].isCurrentSite = true
for (const session of sessionsArray.value) {
const groupKey = session.client || session.host || ''
if (!groups[groupKey]) {
groups[groupKey] = { sessions: [], isCurrentSite: false, isOIDC: !!session.client, displayName: session.client_name || groupKey }
}
groups[groupKey].sessions.push(session)
if (session.is_current_host) groups[groupKey].isCurrentSite = true
}
// Sort sessions within each group by last_renewed descending
for (const host in groups) {
groups[host].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
}
// Sort groups by host name (natural sort)
for (const groupKey in groups) groups[groupKey].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
const sortedHosts = Object.keys(groups).sort(collator.compare)
const sortedGroups = {}
for (const host of sortedHosts) {
sortedGroups[host] = groups[host]
}
return sortedGroups
const sorted = Object.entries(groups).sort(([, a], [, b]) => {
if (a.isOIDC !== b.isOIDC) return a.isOIDC ? 1 : -1
return collator.compare(a.displayName, b.displayName) || collator.compare(a.sessions[0]?.client || '', b.sessions[0]?.client || '')
})
return Object.fromEntries(sorted)
})
</script>
+75 -56
View File
@@ -1,23 +1,38 @@
<template>
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
<h3 class="user-name-heading">
<span class="icon">👤</span>
<span class="user-name-row">
<span class="display-name" :title="name">{{ name }}</span>
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('editName')" title="Edit name"></button>
</span>
</h3>
<div v-if="orgDisplayName || roleName" class="org-role-sub">
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
<div class="role-line" v-if="roleName">{{ roleName }}</div>
</div>
<div class="user-details">
<span class="date-label"><strong>Visits:</strong></span>
<span class="date-value">{{ visits || 0 }}</span>
<span class="date-label"><strong>Registered:</strong></span>
<span class="date-value">{{ formatDate(createdAt) }}</span>
<span class="date-label"><strong>Last seen:</strong></span>
<span class="date-value">{{ formatDate(lastSeen) }}</span>
<div class="user-info-content">
<div class="user-picture">
<span>👤</span>
</div>
<h3 class="user-name-heading">
<span class="user-name-row">
<span class="display-name" :title="name">{{ name }}</span>
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('edit')" title="Edit profile"></button>
</span>
</h3>
<div v-if="orgDisplayName || roleName" class="org-role-sub">
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
<div class="role-line" v-if="roleName">{{ roleName }}</div>
</div>
<div class="info-fields-block">
<div v-if="preferred_username" class="contact-item">🆔 {{ preferred_username }}</div>
<a v-if="email" :href="`mailto:${email}`" class="contact-link"> {{ email }}</a>
<a v-if="telephone" :href="`tel:${telephone}`" class="contact-link">📞 {{ telephone }}</a>
</div>
<div class="info-line">
<span v-if="visits">
<span class="info-date">{{ formatDate(createdAt) }}</span>
<span class="info-punct"> </span>
<span class="info-date">{{ formatDate(lastSeen) }}</span>
<span class="info-punct"> ×</span>
<span class="info-count">{{ visits }}</span>
</span>
<span v-else>
<span class="info-label">Created </span>
<span class="info-date">{{ formatDate(createdAt) }}</span>
<span class="info-punct"> Never signed in</span>
</span>
</div>
</div>
<div v-if="$slots.default" class="user-info-extra">
<slot></slot>
@@ -26,12 +41,15 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { formatDate } from '@/utils/helpers'
const props = defineProps({
name: { type: String, required: true },
email: { type: String, default: null },
preferred_username: { type: String, default: null },
telephone: { type: String, default: null },
visits: { type: [Number, String], default: 0 },
createdAt: { type: [String, Number, Date], default: null },
lastSeen: { type: [String, Number, Date], default: null },
@@ -42,7 +60,7 @@ const props = defineProps({
roleName: { type: String, default: '' }
})
const emit = defineEmits(['saved', 'editName'])
const emit = defineEmits(['saved', 'edit'])
const authStore = useAuthStore()
const userLoaded = computed(() => !!props.name)
@@ -50,55 +68,56 @@ const userLoaded = computed(() => !!props.name)
<style scoped>
.user-info.has-extra {
grid-template-columns: auto 1fr 2fr;
grid-template-columns: minmax(0, 1fr) 14rem;
grid-template-areas:
"heading heading extra"
"org org extra"
"label1 value1 extra"
"label2 value2 extra"
"label3 value3 extra";
"content extra";
gap: 1.5rem;
}
.user-info:not(.has-extra) {
grid-template-columns: auto 1fr;
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"heading heading"
"org org"
"label1 value1"
"label2 value2"
"label3 value3";
"content";
}
@media (max-width: 720px) {
.user-info.has-extra {
grid-template-columns: auto 1fr;
grid-template-columns: 1fr;
grid-template-areas:
"heading heading"
"org org"
"label1 value1"
"label2 value2"
"label3 value3"
"extra extra";
"content"
"extra";
}
}
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
.org-role-sub { grid-area: org; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; }
.info-label:nth-of-type(1) { grid-area: label1; }
.info-value:nth-of-type(2) { grid-area: value1; }
.info-label:nth-of-type(3) { grid-area: label2; }
.info-value:nth-of-type(4) { grid-area: value2; }
.info-label:nth-of-type(5) { grid-area: label3; }
.info-value:nth-of-type(6) { grid-area: value3; }
.user-info-extra { grid-area: extra; padding-left: 2rem; border-left: 1px solid var(--color-border); }
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; }
.user-name-row.editing { flex: 1 1 auto; }
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid var(--color-border-strong); border-radius: 6px; background: var(--color-surface); color: var(--color-text); }
.user-name-heading .name-input { width: auto; }
.name-input:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); }
.user-info-content {
grid-area: content;
display: grid;
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
"picture heading fields"
"picture org fields"
". info info";
gap: 0 1rem;
min-width: 0;
}
.user-picture { grid-area: picture; display: flex; align-items: flex-start; font-size: 2em; line-height: 1; }
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
.org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
.org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.role-line { font-size: .65rem; color: var(--color-text-muted); line-height: 1.1; }
.info-fields-block { grid-area: fields; display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
.contact-item { display: block; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.contact-link { color: var(--color-text); text-decoration: none; display: block; transition: transform 0.1s ease; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.contact-link:hover { transform: scale(1.01); }
.info-line { grid-area: info; line-height: 1.4; font-size: 0.9em; }
.info-date { color: var(--color-text) !important; }
.info-label { color: var(--color-text) !important; }
.info-punct { color: var(--color-text-muted) !important; }
.info-count { color: var(--color-text-muted) !important; }
.user-info-extra { grid-area: extra; padding-left: 1rem; border-left: 1px solid var(--color-border); flex-shrink: 0; }
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; min-width: 0; }
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; }
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
.mini-btn:active:not(:disabled) { transform: translateY(1px); }