- Admin: replace Server Options dialog with per-realm management — realms table on the overview, add/edit/delete realm dialog backed by /auth/api/admin/realms/. Origins may be any well-formed origin; non-subdomain ones are related origins (ROR, max 5) and the dialog points at the .well-known/webauthn URL that must list them. Connectivity checks compare against the edited realm's rp-id and degrade to warnings instead of blocking saves. - Host mode (limited profile) now keys off own_auth_host so realms sharing another realm's auth host serve the full profile locally. - Credential list shows a realm badge on passkeys registered for a different rp-id than the current realm. - Profile shows an enrollment prompt when the user has no passkey for the current realm (e.g. after a cross-realm remote login). - Remote auth permit shows the requesting realm when it differs from the approver's own. - settings cache can be force-refreshed after realm changes.
170 lines
6.1 KiB
Vue
170 lines
6.1 KiB
Vue
<template>
|
|
<div class="credential-list" tabindex="0" @focusin="handleListFocus" @keydown="handleListKeydown">
|
|
<div v-if="loading"><p>Loading credentials...</p></div>
|
|
<div v-else-if="!credentials?.length"><p>No passkeys found.</p></div>
|
|
<template v-else>
|
|
<div
|
|
v-for="credential in credentials"
|
|
:key="credential.credential"
|
|
:class="['credential-item', {
|
|
'current-session': credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid,
|
|
'is-hovered': hoveredCredentialUuid === credential.credential,
|
|
'is-linked-session': hoveredSessionCredentialUuid === credential.credential
|
|
}]"
|
|
tabindex="-1"
|
|
@mousedown.prevent
|
|
@click.capture="handleCardClick"
|
|
@focusin="handleCredentialFocus(credential.credential)"
|
|
@focusout="handleCredentialBlur($event)"
|
|
@keydown="handleItemKeydown($event, credential)"
|
|
>
|
|
<div class="item-top">
|
|
<div class="item-icon">
|
|
<img
|
|
v-if="getCredentialAuthIcon(credential)"
|
|
:src="getCredentialAuthIcon(credential)"
|
|
:alt="getCredentialAuthName(credential)"
|
|
class="auth-icon"
|
|
width="32"
|
|
height="32"
|
|
>
|
|
<span v-else class="auth-emoji">🔑</span>
|
|
</div>
|
|
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
|
|
<div class="item-actions">
|
|
<span v-if="credential.rp_id && settings?.rp_id && credential.rp_id !== settings.rp_id" class="badge badge-realm" :title="`Passkey registered for ${credential.rp_id}`">{{ credential.rp_id }}</span>
|
|
<span v-if="credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid" class="badge badge-current">Current</span>
|
|
<span v-else-if="hoveredCredentialUuid === credential.credential" class="badge badge-current">Selected</span>
|
|
<span v-else-if="hoveredSessionCredentialUuid === credential.credential" class="badge badge-current">Linked</span>
|
|
<button
|
|
v-if="allowDelete"
|
|
@click="$emit('delete', credential)"
|
|
class="btn-card-delete"
|
|
:disabled="credential.is_current_session"
|
|
:title="credential.is_current_session ? 'Cannot delete current session credential' : 'Delete passkey and terminate any linked sessions.'"
|
|
tabindex="-1"
|
|
>❌</button>
|
|
</div>
|
|
</div>
|
|
<div class="item-details">
|
|
<div class="credential-dates">
|
|
<span class="date-label">Created:</span>
|
|
<span class="date-value">{{ formatDate(credential.created_at) }}</span>
|
|
<span class="date-label">Last used:</span>
|
|
<span class="date-value">{{ formatDate(credential.last_used) }}</span>
|
|
<span class="date-label">Last verified:</span>
|
|
<span class="date-value">{{ formatDate(credential.last_verified) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { onMounted, ref } from 'vue'
|
|
import { formatDate } from '@/utils/helpers'
|
|
import { navigateGrid, handleEscape, handleDeleteKey, getDirection } from '@/utils/keynav'
|
|
import { getSettings } from '@/utils/settings'
|
|
|
|
const settings = ref(null)
|
|
onMounted(async () => { settings.value = await getSettings() })
|
|
|
|
const props = defineProps({
|
|
credentials: { type: Array, default: () => [] },
|
|
aaguidInfo: { type: Object, default: () => ({}) },
|
|
loading: { type: Boolean, default: false },
|
|
allowDelete: { type: Boolean, default: false },
|
|
hoveredCredentialUuid: { type: String, default: null },
|
|
hoveredSessionCredentialUuid: { type: String, default: null },
|
|
navigationDisabled: { type: Boolean, default: false },
|
|
})
|
|
|
|
const emit = defineEmits(['delete', 'credentialHover', 'navigate-out'])
|
|
|
|
const handleCredentialFocus = (uuid) => {
|
|
emit('credentialHover', uuid)
|
|
}
|
|
|
|
const handleCredentialBlur = (event) => {
|
|
// Only clear if focus moved outside this element
|
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
|
emit('credentialHover', null)
|
|
}
|
|
}
|
|
|
|
const handleCardClick = (event) => {
|
|
if (!event.currentTarget.matches(':focus')) {
|
|
event.currentTarget.focus()
|
|
event.stopPropagation()
|
|
}
|
|
}
|
|
|
|
const handleDelete = (event, credential) => {
|
|
handleDeleteKey(event, () => {
|
|
if (props.allowDelete && !credential.is_current_session) emit('delete', credential)
|
|
})
|
|
}
|
|
|
|
const handleListFocus = (event) => {
|
|
if (props.navigationDisabled) return
|
|
|
|
const list = event.currentTarget
|
|
// If focus came to the list container itself (not a child), focus first item
|
|
if (event.target === list) {
|
|
const firstItem = list.querySelector('.credential-item')
|
|
if (firstItem) {
|
|
firstItem.focus()
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleListKeydown = (event) => {
|
|
if (props.navigationDisabled) return
|
|
|
|
// Escape emits navigate-out
|
|
handleEscape(event, (dir) => emit('navigate-out', dir))
|
|
}
|
|
|
|
const handleItemKeydown = (event, credential) => {
|
|
// Handle delete (always allowed even with modal)
|
|
handleDelete(event, credential)
|
|
if (event.defaultPrevented) return
|
|
|
|
if (props.navigationDisabled) return
|
|
|
|
// Arrow key navigation
|
|
const direction = getDirection(event)
|
|
if (direction) {
|
|
event.preventDefault()
|
|
const list = event.currentTarget.closest('.credential-list')
|
|
const result = navigateGrid(list, event.currentTarget, direction, { itemSelector: '.credential-item' })
|
|
if (result === 'boundary') {
|
|
emit('navigate-out', direction)
|
|
}
|
|
}
|
|
}
|
|
|
|
const getCredentialAuthName = (credential) => {
|
|
const info = props.aaguidInfo?.[credential.aaguid]
|
|
return info ? info.name : 'Unknown Authenticator'
|
|
}
|
|
|
|
const getCredentialAuthIcon = (credential) => {
|
|
const info = props.aaguidInfo?.[credential.aaguid]
|
|
if (!info) return 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>
|
|
|
|
<style>
|
|
.btn-card-delete {
|
|
display: none;
|
|
}
|
|
.credential-item:focus .btn-card-delete {
|
|
display: block;
|
|
}
|
|
</style>
|