- Serve multiple domains (RP IDs) from one instance: host-based dispatch, per-domain credentials and sessions, domains managed at runtime in the admin UI — previously one RP per instance - Cross-domain sign-in via Related Origin Requests: per-domain related-origins list with a served .well-known/webauthn document - Explicit per-domain origin lists with shell-glob wildcards (**. for apex + any subdomain depth, *. for one level), editable in the admin UI with validation and self-lockout guards - Per-domain auth hosts: the account/admin UI can live on a different host per domain, no longer confined to subdomains of a single RP - CLI: 'paskia init <rp-id [rp-name]' initializes or adds a domain to an existing database; 'paskia migrate' converts legacy databases BREAKING CHANGES (v2.0): - Database schema: config is now per-domain and credentials/sessions carry an rp_id — existing databases must be converted with 'paskia migrate' - Origins are now explicit: main implicitly allowed every subdomain of the RP; configure '**.' origins to reproduce that behavior - CLI: the flat '--rp-id/--rp-name/--origin/--auth/--save' flags are replaced by the 'init' and 'migrate' subcommandsReviewed-on: #4
483 lines
20 KiB
Vue
483 lines
20 KiB
Vue
<template>
|
|
<section class="view-root view-root--profile" data-view="profile">
|
|
<div class="view-header-wrapper">
|
|
<div class="theme-toggle">
|
|
<ThemeSelector />
|
|
</div>
|
|
<header class="view-header">
|
|
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
|
<p class="view-lede">Account dashboard to manage your profile and authentications.</p>
|
|
</header>
|
|
</div>
|
|
|
|
<section class="section-block section-block--constrained" ref="userInfoSection">
|
|
<UserBasicInfo
|
|
v-if="authStore.userInfo?.user"
|
|
ref="userBasicInfo"
|
|
:name="authStore.userInfo.user.display_name"
|
|
:avatar-url="authStore.userInfo.user.avatar_url"
|
|
:avatar-render-version="avatarRenderVersion"
|
|
avatar-clickable
|
|
: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"
|
|
:org-display-name="authStore.userInfo.org.display_name"
|
|
:role-name="authStore.userInfo.role.display_name"
|
|
update-endpoint="/auth/api/user/info"
|
|
@saved="authStore.loadUserInfo()"
|
|
@avatar-click="openAvatarDialog"
|
|
@edit="openEditDialog"
|
|
@keydown="handleUserInfoKeydown"
|
|
>
|
|
<div class="remote-auth-inline">
|
|
<label v-if="!showDeviceInfo" class="remote-auth-label">Code words:</label>
|
|
<RemoteAuthPermit
|
|
ref="pairingEntry"
|
|
title=""
|
|
description=""
|
|
@completed="handlePairingCompleted"
|
|
@error="handlePairingError"
|
|
@device-info-visible="showDeviceInfo = $event"
|
|
/>
|
|
</div>
|
|
<p class="remote-auth-description">Login from another device</p>
|
|
</UserBasicInfo>
|
|
</section>
|
|
|
|
<section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
|
|
<div class="section-header">
|
|
<h2>Your Passkeys</h2>
|
|
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
|
|
</div>
|
|
<div class="section-body">
|
|
<div v-if="missingDomainPasskey" class="domain-enroll-notice">
|
|
<p>You don't have a passkey for <strong>{{ rpName }}</strong> ({{ authStore.settings.rp_id }}) yet. Add one to log in here directly.</p>
|
|
<button @click="addNewCredential" class="btn-primary">Add Passkey for {{ authStore.settings.rp_id }}</button>
|
|
</div>
|
|
<CredentialList
|
|
ref="credentialList"
|
|
:credentials="credentials"
|
|
:aaguid-info="authStore.userInfo.aaguid_info"
|
|
:loading="authStore.isLoading"
|
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
|
:hovered-session-credential-uuid="hoveredSession?.credential"
|
|
:navigation-disabled="hasActiveModal"
|
|
allow-delete
|
|
@delete="handleDelete"
|
|
@credential-hover="hoveredCredentialUuid = $event"
|
|
@navigate-out="handleCredentialNavigateOut"
|
|
/>
|
|
<div class="button-row" ref="credentialButtons">
|
|
<button @click="addNewCredential" class="btn-primary" @keydown="handleCredentialButtonKeydown">Register New</button>
|
|
<button @click="showRegLink = true" class="btn-secondary" @keydown="handleCredentialButtonKeydown">Another Device</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<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"
|
|
@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."
|
|
/>
|
|
|
|
<section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
|
|
<div class="button-row" ref="logoutButtons">
|
|
<button
|
|
type="button"
|
|
class="btn-secondary"
|
|
@click="goBack"
|
|
@keydown="handleLogoutButtonKeydown"
|
|
>
|
|
Back
|
|
</button>
|
|
<button v-if="!hasMultipleSessions" @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">Logout</button>
|
|
<template v-else>
|
|
<button @click="logout" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">Logout</button>
|
|
<button @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">All</button>
|
|
</template>
|
|
</div>
|
|
<div class="logout-footer">
|
|
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
|
|
<p class="logout-note" v-else><strong>Logout</strong> this session on {{ currentSessionHost }}, or <strong>All</strong> sessions across all sites and devices for {{ rpName }}.</p>
|
|
<a class="paskia-version" href="https://git.zi.fi/leovasanko/paskia" target="_blank" rel="noopener noreferrer">Paskia {{ paskiaVersion }}</a>
|
|
</div>
|
|
</section>
|
|
|
|
<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>
|
|
|
|
<ProfilePictureEditorModal
|
|
v-if="showAvatarDialog && currentAvatarEndpoint"
|
|
:endpoint="currentAvatarEndpoint"
|
|
:picture-url="authStore.userInfo?.user?.avatar_url"
|
|
:render-version="avatarRenderVersion"
|
|
@close="closeAvatarDialog"
|
|
@updated="handleProfilePictureUpdated"
|
|
/>
|
|
|
|
<RegistrationLinkModal
|
|
v-if="showRegLink"
|
|
endpoint="/auth/api/user/create-link"
|
|
@close="showRegLink = false"
|
|
@copied="onLinkCopied"
|
|
/>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
|
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
|
import CredentialList from '@/components/CredentialList.vue'
|
|
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
|
|
import ThemeSelector from '@/components/ThemeSelector.vue'
|
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
|
import Modal from '@/components/Modal.vue'
|
|
import SessionList from '@/components/SessionList.vue'
|
|
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
|
import RemoteAuthPermit from '@/components/RemoteAuthPermit.vue'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
|
import passkey from '@/utils/passkey'
|
|
import { goBack } from '@/utils/helpers'
|
|
import { apiJson } from 'paskia'
|
|
import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@/utils/keynav'
|
|
|
|
const authStore = useAuthStore()
|
|
const updateInterval = ref(null)
|
|
const showEditDialog = ref(false)
|
|
const showAvatarDialog = ref(false)
|
|
const showRegLink = ref(false)
|
|
const editName = ref('')
|
|
const editEmail = ref('')
|
|
const editUsername = ref('')
|
|
const editTelephone = ref('')
|
|
const avatarRenderVersion = ref(0)
|
|
const saving = ref(false)
|
|
const editError = ref('')
|
|
const hoveredCredentialUuid = ref(null)
|
|
const hoveredSession = ref(null)
|
|
const showDeviceInfo = ref(false)
|
|
const pairingEntry = ref(null)
|
|
const credentialList = ref(null)
|
|
const credentialButtons = ref(null)
|
|
const sessionList = ref(null)
|
|
const logoutButtons = ref(null)
|
|
const breadcrumbs = ref(null)
|
|
const userInfoSection = ref(null)
|
|
|
|
// Check if any modal/dialog is open (blocks arrow key navigation)
|
|
const hasActiveModal = computed(() => showEditDialog.value || showAvatarDialog.value || showRegLink.value)
|
|
|
|
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)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (updateInterval.value) clearInterval(updateInterval.value)
|
|
})
|
|
|
|
const currentAvatarEndpoint = computed(() => {
|
|
const userUuid = authStore.userInfo?.user?.uuid
|
|
if (!userUuid) return null
|
|
return `/auth/api/user/${userUuid}/profile.webp`
|
|
})
|
|
|
|
const openAvatarDialog = () => {
|
|
showAvatarDialog.value = true
|
|
}
|
|
|
|
const closeAvatarDialog = () => {
|
|
showAvatarDialog.value = false
|
|
}
|
|
|
|
const handleProfilePictureUpdated = async () => {
|
|
await authStore.loadUserInfo()
|
|
avatarRenderVersion.value += 1
|
|
}
|
|
|
|
const addNewCredential = async () => {
|
|
try {
|
|
await passkey.register(null, null, () => {
|
|
authStore.showMessage('Adding new passkey...', 'info')
|
|
})
|
|
await authStore.loadUserInfo()
|
|
authStore.showMessage('New passkey added successfully!', 'success', 3000)
|
|
} catch (error) {
|
|
console.error('Failed to add new passkey:', error)
|
|
authStore.showMessage(error.message, 'error')
|
|
}
|
|
}
|
|
|
|
const handlePairingCompleted = () => {
|
|
authStore.showMessage('The other device is now signed in!', 'success', 4000)
|
|
// Reset the form after a delay
|
|
setTimeout(() => pairingEntry.value?.reset(), 3000)
|
|
}
|
|
|
|
const handlePairingError = (message) => {
|
|
// Error is already shown in the component, optionally show global message for severe errors
|
|
if (!message.includes('cancelled')) {
|
|
authStore.showMessage(message, 'error', 4000)
|
|
}
|
|
}
|
|
|
|
const onLinkCopied = () => {
|
|
authStore.showMessage('📋 Link copied! Send it to your other device.')
|
|
showRegLink.value = false
|
|
}
|
|
|
|
// Helper to focus preferred button in a row (primary first, or first button)
|
|
const focusPreferredButton = (container) => {
|
|
focusPreferred(container, { primarySelector: '.btn-primary', itemSelector: 'button' })
|
|
}
|
|
|
|
// Navigation between components
|
|
const handleBreadcrumbKeydown = (event) => {
|
|
if (hasActiveModal.value) return // Block navigation when modal is open
|
|
|
|
const direction = getDirection(event)
|
|
if (!direction) return
|
|
|
|
// Left/right handled internally by Breadcrumbs component
|
|
if (direction === 'down') {
|
|
event.preventDefault()
|
|
// Move to user info section - always focus edit button first
|
|
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
|
|
}
|
|
// ArrowUp at the top does nothing
|
|
}
|
|
|
|
const handleUserInfoKeydown = (event) => {
|
|
if (hasActiveModal.value) return // Block navigation when modal is open
|
|
|
|
const direction = getDirection(event)
|
|
if (!direction) return
|
|
|
|
event.preventDefault()
|
|
const itemSelector = '.user-picture-btn, .mini-btn, .pairing-input'
|
|
|
|
if (direction === 'left' || direction === 'right') {
|
|
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
|
|
} else if (direction === 'up') {
|
|
// Move to breadcrumbs - focus current page crumb
|
|
breadcrumbs.value?.focusCurrent?.()
|
|
} else if (direction === 'down') {
|
|
// Move to credential list
|
|
credentialList.value?.$el?.focus()
|
|
}
|
|
}
|
|
|
|
const handleCredentialNavigateOut = (direction) => {
|
|
if (hasActiveModal.value) return // Block navigation when modal is open
|
|
|
|
if (direction === 'down' || direction === 'right') {
|
|
// Focus preferred button in credential section
|
|
focusPreferredButton(credentialButtons.value)
|
|
} else if (direction === 'up' || direction === 'left') {
|
|
// Focus user info section - always focus edit button first
|
|
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
|
|
}
|
|
}
|
|
|
|
const handleCredentialButtonKeydown = (event) => {
|
|
if (hasActiveModal.value) return // Block navigation when modal is open
|
|
|
|
const direction = getDirection(event)
|
|
if (!direction) return
|
|
|
|
event.preventDefault()
|
|
|
|
if (direction === 'left' || direction === 'right') {
|
|
navigateButtonRow(credentialButtons.value, event.target, direction, { itemSelector: 'button' })
|
|
} else if (direction === 'up') {
|
|
// Move back to credential list
|
|
focusAtIndex(credentialList.value?.$el, 0, { itemSelector: '.credential-item' })
|
|
} else if (direction === 'down') {
|
|
// Move to session list
|
|
focusAtIndex(sessionList.value?.$el, 0, { itemSelector: '.session-group' })
|
|
}
|
|
}
|
|
|
|
const handleSessionNavigateOut = (direction) => {
|
|
if (hasActiveModal.value) return // Block navigation when modal is open
|
|
|
|
if (direction === 'up') {
|
|
// Focus preferred button in credential section
|
|
focusPreferredButton(credentialButtons.value)
|
|
} else if (direction === 'down') {
|
|
// Focus preferred button in logout section
|
|
focusPreferredButton(logoutButtons.value)
|
|
}
|
|
}
|
|
|
|
const handleLogoutButtonKeydown = (event) => {
|
|
if (hasActiveModal.value) return // Block navigation when modal is open
|
|
|
|
const direction = getDirection(event)
|
|
if (!direction) return
|
|
|
|
event.preventDefault()
|
|
|
|
if (direction === 'left' || direction === 'right') {
|
|
navigateButtonRow(logoutButtons.value, event.target, direction, { itemSelector: 'button' })
|
|
} else if (direction === 'up') {
|
|
// Move back to session list - focus last group
|
|
focusAtIndex(sessionList.value?.$el, -1, { itemSelector: '.session-group' })
|
|
}
|
|
// ArrowDown at the bottom does nothing
|
|
}
|
|
|
|
const handleDelete = async (credential) => {
|
|
const credentialId = credential?.credential
|
|
if (!credentialId) return
|
|
try {
|
|
await authStore.deleteCredential(credentialId)
|
|
authStore.showMessage('Passkey deleted! You should also remove it from your password manager or device.', 'success', 3000)
|
|
} catch (error) { authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error') }
|
|
}
|
|
|
|
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
|
|
const paskiaVersion = computed(() => authStore.settings?.version || '')
|
|
const sessions = computed(() => authStore.userInfo.sessions)
|
|
const currentSessionHost = computed(() => {
|
|
const currentSession = Object.values(sessions.value).find(session => session.is_current)
|
|
return currentSession?.host || 'this host'
|
|
})
|
|
const terminatingSessions = ref({})
|
|
|
|
const terminateSession = async (session) => {
|
|
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 openEditDialog = () => { showEditDialog.value = true }
|
|
const isAdmin = computed(() => {
|
|
const perms = Object.values(authStore.userInfo.permissions).map(p => p.scope)
|
|
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
|
|
})
|
|
const hasMultipleSessions = computed(() => Object.keys(sessions.value).length > 1)
|
|
const credentials = computed(() =>
|
|
Object.entries(authStore.userInfo.credentials).map(([uuid, c]) => ({ ...c, credential: uuid }))
|
|
)
|
|
const missingDomainPasskey = computed(() => {
|
|
const rpId = authStore.settings?.rp_id
|
|
if (!rpId) return false
|
|
return !credentials.value.some(c => c.rp_id === rpId)
|
|
})
|
|
const useWideLayout = computed(() => {
|
|
// Check if any single site has more than 8 sessions
|
|
const groups = {}
|
|
for (const session of Object.values(sessions.value)) {
|
|
const host = session.host || ''
|
|
if (!groups[host]) groups[host] = []
|
|
groups[host].push(session)
|
|
}
|
|
const hasLargeSessionGroup = Object.values(groups).some(group => group.length > 8)
|
|
|
|
// Check if passkeys exceed 8
|
|
const hasManyCredentials = credentials.value.length > 8
|
|
|
|
return hasLargeSessionGroup || hasManyCredentials
|
|
})
|
|
const breadcrumbEntries = computed(() => { const entries = [{ label: 'My Profile', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
|
|
|
|
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
|
|
let changed = 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 })
|
|
changed = true
|
|
}
|
|
if (changed) {
|
|
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>
|
|
|
|
<style scoped>
|
|
.section-header { display: flex; flex-direction: column; gap: 0.4rem; }
|
|
.empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; }
|
|
.logout-note { margin: 0; color: var(--color-text-muted); }
|
|
.logout-footer { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin-top: 0.75rem; }
|
|
.paskia-version { color: var(--color-text-muted); text-decoration: none; white-space: nowrap; font-weight: 700; }
|
|
.paskia-version:hover { color: var(--color-link-hover); }
|
|
.remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
.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>
|