feat(auth): add PasskeyManager component and production config

- PasskeyManager.svelte: reusable component for listing, registering,
  renaming, and deleting passkeys (German defaults, fully translatable)
- Production env: WEBAUTHN_RP_ID=mana.how and WEBAUTHN_ORIGINS for all
  *.mana.how subdomains in docker-compose.macmini.yml
- Local DB: passkeys table created via direct SQL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-26 10:49:57 +01:00
parent 3091da914e
commit c4d55209a4
3 changed files with 864 additions and 0 deletions

View file

@ -229,6 +229,9 @@ services:
STORAGE_BACKEND_URL: http://storage-backend:3035
ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
MANA_LLM_URL: http://mana-llm:3025
# WebAuthn / Passkeys
WEBAUTHN_RP_ID: mana.how
WEBAUTHN_ORIGINS: https://mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://manadeck.mana.how,https://mukke.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://planta.mana.how,https://playground.mana.how,https://presi.mana.how,https://questions.mana.how,https://skilltree.mana.how,https://storage.mana.how,https://todo.mana.how,https://zitare.mana.how
volumes:
- analytics_data:/data/analytics
ports:

View file

@ -0,0 +1,859 @@
<script lang="ts">
/** Translation strings for the PasskeyManager */
export interface PasskeyManagerTranslations {
title: string;
noPasskeys: string;
registerButton: string;
renameButton: string;
deleteButton: string;
cancelButton: string;
saveButton: string;
confirmDeleteTitle: string;
confirmDeleteMessage: string;
created: string;
lastUsed: string;
never: string;
backedUp: string;
notBackedUp: string;
browserNotSupported: string;
registerNamePlaceholder: string;
registerNameLabel: string;
registerTitle: string;
renamePlaceholder: string;
errorGeneric: string;
daysAgo: (days: number) => string;
hoursAgo: (hours: number) => string;
minutesAgo: (minutes: number) => string;
justNow: string;
}
interface Passkey {
id: string;
credentialId: string;
deviceType: string;
backedUp: boolean;
friendlyName: string | null;
lastUsedAt: string | null;
createdAt: string;
}
interface Props {
passkeys: Passkey[];
onRegister: (friendlyName?: string) => Promise<{ success: boolean; error?: string }>;
onDelete: (passkeyId: string) => Promise<{ success: boolean; error?: string }>;
onRename: (
passkeyId: string,
friendlyName: string
) => Promise<{ success: boolean; error?: string }>;
onRefresh: () => Promise<void>;
passkeyAvailable: boolean;
primaryColor?: string;
translations?: Partial<PasskeyManagerTranslations>;
}
const defaultTranslations: PasskeyManagerTranslations = {
title: 'Passkeys',
noPasskeys: 'Noch keine Passkeys registriert.',
registerButton: 'Neuen Passkey registrieren',
renameButton: 'Umbenennen',
deleteButton: 'Löschen',
cancelButton: 'Abbrechen',
saveButton: 'Speichern',
confirmDeleteTitle: 'Passkey löschen',
confirmDeleteMessage:
'Möchtest du diesen Passkey wirklich löschen? Du kannst dich dann nicht mehr damit anmelden.',
created: 'Erstellt',
lastUsed: 'Zuletzt',
never: 'Nie verwendet',
backedUp: 'Synchronisiert',
notBackedUp: 'Nur auf diesem Gerät',
browserNotSupported:
'Dein Browser unterstützt keine Passkeys. Bitte verwende einen aktuellen Browser wie Chrome, Safari oder Firefox.',
registerNamePlaceholder: 'z.B. MacBook Pro, iPhone',
registerNameLabel: 'Name für den Passkey (optional)',
registerTitle: 'Neuen Passkey registrieren',
renamePlaceholder: 'Neuer Name',
errorGeneric: 'Ein Fehler ist aufgetreten.',
daysAgo: (days: number) => (days === 1 ? 'vor 1 Tag' : `vor ${days} Tagen`),
hoursAgo: (hours: number) => (hours === 1 ? 'vor 1 Stunde' : `vor ${hours} Stunden`),
minutesAgo: (minutes: number) => (minutes === 1 ? 'vor 1 Minute' : `vor ${minutes} Minuten`),
justNow: 'Gerade eben',
};
let {
passkeys,
onRegister,
onDelete,
onRename,
onRefresh,
passkeyAvailable,
primaryColor = '#6366f1',
translations,
}: Props = $props();
const t = $derived({ ...defaultTranslations, ...translations });
// State
let editingId = $state<string | null>(null);
let editName = $state('');
let deletingId = $state<string | null>(null);
let showRegisterForm = $state(false);
let registerName = $state('');
let loading = $state(false);
let error = $state<string | null>(null);
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
return `${day}.${month}.${year}`;
}
function formatRelativeTime(dateStr: string | null): string {
if (!dateStr) return t.never;
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMinutes < 1) return t.justNow;
if (diffMinutes < 60) return t.minutesAgo(diffMinutes);
if (diffHours < 24) return t.hoursAgo(diffHours);
return t.daysAgo(diffDays);
}
function getDeviceIcon(deviceType: string): string {
const type = deviceType.toLowerCase();
if (type.includes('phone') || type.includes('mobile')) return '\u{1F4F1}';
if (type.includes('tablet')) return '\u{1F4F1}';
return '\u{1F511}';
}
function getDisplayName(passkey: Passkey): string {
return passkey.friendlyName || 'Passkey';
}
function startRename(passkey: Passkey) {
editingId = passkey.id;
editName = passkey.friendlyName || '';
error = null;
}
function cancelRename() {
editingId = null;
editName = '';
}
async function saveRename(passkeyId: string) {
if (!editName.trim()) return;
loading = true;
error = null;
const result = await onRename(passkeyId, editName.trim());
loading = false;
if (result.success) {
editingId = null;
editName = '';
await onRefresh();
} else {
error = result.error || t.errorGeneric;
}
}
function confirmDelete(passkeyId: string) {
deletingId = passkeyId;
error = null;
}
function cancelDelete() {
deletingId = null;
}
async function executeDelete() {
if (!deletingId) return;
loading = true;
error = null;
const result = await onDelete(deletingId);
loading = false;
if (result.success) {
deletingId = null;
await onRefresh();
} else {
error = result.error || t.errorGeneric;
}
}
function openRegisterForm() {
showRegisterForm = true;
registerName = '';
error = null;
}
function cancelRegister() {
showRegisterForm = false;
registerName = '';
}
async function handleRegister() {
loading = true;
error = null;
const result = await onRegister(registerName.trim() || undefined);
loading = false;
if (result.success) {
showRegisterForm = false;
registerName = '';
await onRefresh();
} else {
error = result.error || t.errorGeneric;
}
}
</script>
<div class="passkey-manager" style:--primary-color={primaryColor}>
<h3 class="pm-title">{t.title}</h3>
{#if !passkeyAvailable}
<div class="pm-warning" role="alert">
<span class="pm-warning-icon">{'\u26A0\uFE0F'}</span>
<p>{t.browserNotSupported}</p>
</div>
{:else}
{#if error}
<div class="pm-error" role="alert">
<p>{error}</p>
</div>
{/if}
{#if passkeys.length === 0}
<p class="pm-empty">{t.noPasskeys}</p>
{:else}
<ul class="pm-list">
{#each passkeys as passkey (passkey.id)}
<li class="pm-item">
{#if deletingId === passkey.id}
<div class="pm-confirm-delete">
<p class="pm-confirm-title">{t.confirmDeleteTitle}</p>
<p class="pm-confirm-message">{t.confirmDeleteMessage}</p>
<div class="pm-confirm-actions">
<button
type="button"
class="pm-btn pm-btn-cancel"
onclick={cancelDelete}
disabled={loading}
>
{t.cancelButton}
</button>
<button
type="button"
class="pm-btn pm-btn-danger"
onclick={executeDelete}
disabled={loading}
>
{#if loading}
<svg
class="pm-spinner"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" stroke-opacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round" />
</svg>
{/if}
{t.deleteButton}
</button>
</div>
</div>
{:else if editingId === passkey.id}
<div class="pm-rename-form">
<div class="pm-rename-input-row">
<input
type="text"
class="pm-input"
bind:value={editName}
placeholder={t.renamePlaceholder}
onkeydown={(e) => {
if (e.key === 'Enter') saveRename(passkey.id);
if (e.key === 'Escape') cancelRename();
}}
/>
</div>
<div class="pm-rename-actions">
<button
type="button"
class="pm-btn pm-btn-cancel"
onclick={cancelRename}
disabled={loading}
>
{t.cancelButton}
</button>
<button
type="button"
class="pm-btn pm-btn-primary"
onclick={() => saveRename(passkey.id)}
disabled={loading || !editName.trim()}
>
{t.saveButton}
</button>
</div>
</div>
{:else}
<div class="pm-item-header">
<span class="pm-item-icon">{getDeviceIcon(passkey.deviceType)}</span>
<div class="pm-item-info">
<span class="pm-item-name">{getDisplayName(passkey)}</span>
<span class="pm-item-meta">
{t.created}: {formatDate(passkey.createdAt)}
</span>
<span class="pm-item-meta">
{t.lastUsed}: {formatRelativeTime(passkey.lastUsedAt)}
</span>
{#if passkey.backedUp}
<span class="pm-item-badge pm-badge-synced">{t.backedUp}</span>
{:else}
<span class="pm-item-badge pm-badge-local">{t.notBackedUp}</span>
{/if}
</div>
</div>
<div class="pm-item-actions">
<button
type="button"
class="pm-btn pm-btn-ghost"
onclick={() => startRename(passkey)}
>
{t.renameButton}
</button>
<button
type="button"
class="pm-btn pm-btn-ghost pm-btn-ghost-danger"
onclick={() => confirmDelete(passkey.id)}
>
{t.deleteButton}
</button>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
{#if showRegisterForm}
<div class="pm-register-form">
<p class="pm-register-title">{t.registerTitle}</p>
<label class="pm-register-label" for="passkey-name">
{t.registerNameLabel}
</label>
<input
id="passkey-name"
type="text"
class="pm-input"
bind:value={registerName}
placeholder={t.registerNamePlaceholder}
onkeydown={(e) => {
if (e.key === 'Enter') handleRegister();
if (e.key === 'Escape') cancelRegister();
}}
/>
<div class="pm-register-actions">
<button
type="button"
class="pm-btn pm-btn-cancel"
onclick={cancelRegister}
disabled={loading}
>
{t.cancelButton}
</button>
<button
type="button"
class="pm-btn pm-btn-primary"
onclick={handleRegister}
disabled={loading}
>
{#if loading}
<svg
class="pm-spinner"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" stroke-opacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round" />
</svg>
{/if}
{t.registerButton}
</button>
</div>
</div>
{:else}
<button
type="button"
class="pm-btn pm-btn-register"
onclick={openRegisterForm}
disabled={loading}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{t.registerButton}
</button>
{/if}
{/if}
</div>
<style>
.passkey-manager {
width: 100%;
max-width: 480px;
}
.pm-title {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 1rem;
color: #1f2937;
}
:global(.dark) .pm-title {
color: #f3f4f6;
}
/* Warning */
.pm-warning {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
:global(.dark) .pm-warning {
background: #78350f;
border-color: #b45309;
}
.pm-warning-icon {
flex-shrink: 0;
font-size: 1rem;
line-height: 1.5;
}
.pm-warning p {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
color: #92400e;
}
:global(.dark) .pm-warning p {
color: #fef3c7;
}
/* Error */
.pm-error {
padding: 0.625rem 0.875rem;
background: #fef2f2;
border: 1px solid #fca5a5;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
:global(.dark) .pm-error {
background: #450a0a;
border-color: #991b1b;
}
.pm-error p {
margin: 0;
font-size: 0.8125rem;
color: #dc2626;
}
:global(.dark) .pm-error p {
color: #fca5a5;
}
/* Empty state */
.pm-empty {
font-size: 0.875rem;
color: #6b7280;
margin: 0 0 1rem;
}
:global(.dark) .pm-empty {
color: #9ca3af;
}
/* Passkey list */
.pm-list {
list-style: none;
padding: 0;
margin: 0 0 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.pm-item {
padding: 0.875rem 1rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.625rem;
transition: border-color 150ms ease;
}
.pm-item:hover {
border-color: #d1d5db;
}
:global(.dark) .pm-item {
background: #1f2937;
border-color: #374151;
}
:global(.dark) .pm-item:hover {
border-color: #4b5563;
}
/* Item header */
.pm-item-header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.pm-item-icon {
font-size: 1.25rem;
line-height: 1;
flex-shrink: 0;
margin-top: 0.125rem;
}
.pm-item-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.pm-item-name {
font-size: 0.9375rem;
font-weight: 500;
color: #111827;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.dark) .pm-item-name {
color: #f9fafb;
}
.pm-item-meta {
font-size: 0.75rem;
color: #6b7280;
}
:global(.dark) .pm-item-meta {
color: #9ca3af;
}
/* Badges */
.pm-item-badge {
display: inline-block;
font-size: 0.6875rem;
padding: 0.125rem 0.5rem;
border-radius: 999px;
margin-top: 0.25rem;
width: fit-content;
}
.pm-badge-synced {
background: #dcfce7;
color: #166534;
}
:global(.dark) .pm-badge-synced {
background: #052e16;
color: #86efac;
}
.pm-badge-local {
background: #fef3c7;
color: #92400e;
}
:global(.dark) .pm-badge-local {
background: #451a03;
color: #fcd34d;
}
/* Item actions */
.pm-item-actions {
display: flex;
gap: 0.375rem;
margin-top: 0.625rem;
padding-top: 0.625rem;
border-top: 1px solid #e5e7eb;
}
:global(.dark) .pm-item-actions {
border-top-color: #374151;
}
/* Buttons */
.pm-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.4375rem 0.875rem;
font-size: 0.8125rem;
font-weight: 500;
border-radius: 0.5rem;
border: 1px solid transparent;
cursor: pointer;
transition: all 150ms ease;
line-height: 1.25;
}
.pm-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pm-btn-primary {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.pm-btn-primary:hover:not(:disabled) {
filter: brightness(0.9);
}
.pm-btn-cancel {
background: transparent;
color: #6b7280;
border-color: #d1d5db;
}
.pm-btn-cancel:hover:not(:disabled) {
background: #f3f4f6;
}
:global(.dark) .pm-btn-cancel {
color: #9ca3af;
border-color: #4b5563;
}
:global(.dark) .pm-btn-cancel:hover:not(:disabled) {
background: #374151;
}
.pm-btn-danger {
background: #dc2626;
color: white;
border-color: #dc2626;
}
.pm-btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.pm-btn-ghost {
background: transparent;
color: #6b7280;
padding: 0.3125rem 0.625rem;
font-size: 0.75rem;
}
.pm-btn-ghost:hover:not(:disabled) {
background: #f3f4f6;
color: #374151;
}
:global(.dark) .pm-btn-ghost {
color: #9ca3af;
}
:global(.dark) .pm-btn-ghost:hover:not(:disabled) {
background: #374151;
color: #d1d5db;
}
.pm-btn-ghost-danger:hover:not(:disabled) {
background: #fef2f2;
color: #dc2626;
}
:global(.dark) .pm-btn-ghost-danger:hover:not(:disabled) {
background: #450a0a;
color: #fca5a5;
}
.pm-btn-register {
width: 100%;
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 500;
background: transparent;
color: var(--primary-color);
border: 1px dashed var(--primary-color);
border-radius: 0.625rem;
}
.pm-btn-register:hover:not(:disabled) {
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
}
/* Input */
.pm-input {
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
background: white;
color: #111827;
outline: none;
transition: border-color 150ms ease;
box-sizing: border-box;
}
.pm-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color) 20%, transparent);
}
:global(.dark) .pm-input {
background: #111827;
border-color: #4b5563;
color: #f3f4f6;
}
:global(.dark) .pm-input:focus {
border-color: var(--primary-color);
}
/* Rename form */
.pm-rename-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.pm-rename-input-row {
display: flex;
gap: 0.5rem;
}
.pm-rename-actions {
display: flex;
gap: 0.375rem;
justify-content: flex-end;
}
/* Confirm delete */
.pm-confirm-delete {
text-align: center;
}
.pm-confirm-title {
font-size: 0.875rem;
font-weight: 600;
color: #dc2626;
margin: 0 0 0.25rem;
}
.pm-confirm-message {
font-size: 0.8125rem;
color: #6b7280;
margin: 0 0 0.75rem;
line-height: 1.4;
}
:global(.dark) .pm-confirm-message {
color: #9ca3af;
}
.pm-confirm-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
}
/* Register form */
.pm-register-form {
padding: 1rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.625rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
:global(.dark) .pm-register-form {
background: #1f2937;
border-color: #374151;
}
.pm-register-title {
font-size: 0.9375rem;
font-weight: 600;
color: #111827;
margin: 0;
}
:global(.dark) .pm-register-title {
color: #f3f4f6;
}
.pm-register-label {
font-size: 0.8125rem;
color: #6b7280;
}
:global(.dark) .pm-register-label {
color: #9ca3af;
}
.pm-register-actions {
display: flex;
gap: 0.375rem;
justify-content: flex-end;
margin-top: 0.25rem;
}
/* Spinner */
.pm-spinner {
width: 16px;
height: 16px;
animation: pm-spin 0.75s linear infinite;
flex-shrink: 0;
}
@keyframes pm-spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -8,6 +8,7 @@ export { default as GuestWelcomeModal } from './components/GuestWelcomeModal.sve
export { default as AuthGateModal } from './components/AuthGateModal.svelte';
export { default as SessionExpiredBanner } from './components/SessionExpiredBanner.svelte';
export { default as AuthGate } from './components/AuthGate.svelte';
export { default as PasskeyManager } from './components/PasskeyManager.svelte';
// Utilities
export {
@ -26,3 +27,4 @@ export type {
AuthGateAction,
AuthGateTranslations,
} from './types';
export type { PasskeyManagerTranslations } from './components/PasskeyManager.svelte';