/** * Shared presentation helpers for security keys (YubiKey) — used by the overview page * (/users/security-keys), the profile / user-edit panel and the enrol + revoke modals so * dates, transport labels, initials and WebAuthn error texts read the same everywhere. */ export interface SecurityKeyRow { credentialId: string nickname: string createdAt?: number | null lastUsedAt?: number | null transports?: string[] enrolledBy?: number | null enrolledByName?: string | null userId?: number userName?: string userValue?: string } export const useSecurityKeyFormat = () => { const { t, lang } = useTranslation() as any const locale = computed(() => String(lang?.value || 'en-US')) const fmtAbsolute = (ts?: number | string | null): string => { if (!ts) return '' try { return new Date(ts).toLocaleString(locale.value, { dateStyle: 'medium', timeStyle: 'short' }) } catch { return '' } } /** "today 10:21" / "yesterday" / "3 days ago" / absolute date beyond 30 days. */ const fmtRelative = (ts?: number | string | null): string => { if (!ts) return '' const d = new Date(ts) if (Number.isNaN(d.getTime())) return '' const now = Date.now() const diffDays = Math.floor((now - d.getTime()) / 86_400_000) const time = d.toLocaleTimeString(locale.value, { hour: '2-digit', minute: '2-digit' }) const tr: any = t.value || {} if (diffDays <= 0) return `${tr.today || 'today'} ${time}` if (diffDays === 1) return `${tr.yesterday || 'yesterday'} ${time}` if (diffDays < 30) { try { return new Intl.RelativeTimeFormat(locale.value, { numeric: 'auto' }).format(-diffDays, 'day') } catch { return `${diffDays} d` } } try { return d.toLocaleDateString(locale.value, { dateStyle: 'medium' }) } catch { return '' } } const transportMap = (): Record => ({ usb: 'USB', nfc: 'NFC', ble: 'Bluetooth', internal: (t.value as any)?.transport_internal || 'Device', hybrid: 'Phone', }) const transportLabel = (list?: string[] | null): string => { const map = transportMap() return (list || []).map(x => map[x] || x).join(', ') || '—' } const transportItems = (list?: string[] | null) => { const map = transportMap() const icons: Record = { usb: 'i-mdi-usb-port', nfc: 'i-mdi-nfc', ble: 'i-mdi-bluetooth', internal: 'i-mdi-cellphone-key', hybrid: 'i-mdi-cellphone-link', } return (list || []).map(x => ({ key: x, label: map[x] || x, icon: icons[x] || 'i-mdi-key-variant' })) } const initials = (name?: string | null): string => { const parts = String(name || '').trim().split(/\s+/).filter(Boolean) if (!parts.length) return '?' return (parts[0]![0]! + (parts.length > 1 ? parts[parts.length - 1]![0]! : '')).toUpperCase() } /** Map a WebAuthn / fetch failure to a human sentence. */ const errorMessage = (err: any, fallback?: string): string => { const tr: any = t.value || {} const name = String(err?.name || '') if (name === 'InvalidStateError') return tr.security_key_already_enrolled || 'This key is already enrolled for this user.' if (name === 'NotAllowedError' || name === 'AbortError') return tr.security_key_login_cancelled_hint || 'No security key was read. Tap the key again.' if (name === 'NotSupportedError') return tr.security_key_not_supported || 'Your browser or device does not support security keys.' if (name === 'SecurityError') return tr.security_key_origin_error || 'This page is not allowed to talk to security keys (origin mismatch).' return err?.data?.statusMessage || err?.data?.message || err?.message || fallback || tr.security_key_error || 'Could not complete the security key operation.' } /** Was the failure just the user cancelling / nothing tapped? (→ no error toast) */ const isCancelled = (err: any): boolean => { const name = String(err?.name || '') return name === 'AbortError' || name === 'NotAllowedError' } return { fmtAbsolute, fmtRelative, transportLabel, transportItems, initials, errorMessage, isCancelled } }