Files
tabletten/components/Passkeys.tsx
T
admin 96043efe46 V 1.3.0 Anmeldung per Passkey (WebAuthn)
Passkey-Login wie in ausgaben-next/werte-next: auf der Login-Seite "Mit
Passkey anmelden", Verwaltung der eigenen Passkeys unter /einstellungen
(Button oben rechts). Das Passwort bleibt als Alternative bestehen.

- lib/webauthn.ts, lib/passkeys.ts sowie API-Routen unter /api/passkey
- proxy.ts: /api/passkey/authenticate ohne Session erreichbar
- Styles als eigene CSS-Klassen in globals.css (kein Tailwind in diesem
  Projekt), passend zum vorhandenen login-*/app-*-Schema
- Tabelle tabletten_passkeys in der DB medizin: app-spezifischer Name,
  damit sich Apps desselben Stacks nicht gegenseitig Passkeys anzeigen
  oder loeschen
- RP-Konfiguration ueber TABLETTEN_RP_*, da sich die .env auf dem Server
  mit anderen Apps teilt, die RP_ID/RP_ORIGIN bereits belegen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 20:17:11 +02:00

136 lines
4.3 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { startRegistration } from '@simplewebauthn/browser';
interface Passkey {
credentialId: string;
label: string;
createdAt: string | null;
lastUsedAt: string | null;
}
function formatDate(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return '—';
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
export default function Passkeys() {
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [label, setLabel] = useState('');
function load() {
return fetch('/api/passkey')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error())))
.then((data) => setPasskeys(data.passkeys ?? []))
.catch(() => setError('Passkeys konnten nicht geladen werden.'))
.finally(() => setLoading(false));
}
useEffect(() => {
load();
}, []);
async function handleRegister() {
setError(null);
setBusy(true);
try {
const optRes = await fetch('/api/passkey/register');
if (!optRes.ok) throw new Error('Optionen konnten nicht geladen werden.');
const optionsJSON = await optRes.json();
const response = await startRegistration({ optionsJSON });
const verifyRes = await fetch('/api/passkey/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ response, label }),
});
if (!verifyRes.ok) {
const data = await verifyRes.json().catch(() => null);
throw new Error(data?.error ?? 'Passkey konnte nicht registriert werden.');
}
setLabel('');
await load();
} catch (err) {
if (err instanceof Error && err.name === 'NotAllowedError') {
setError(null); // Abbruch durch Nutzer
} else {
setError(err instanceof Error ? err.message : 'Passkey konnte nicht registriert werden.');
}
} finally {
setBusy(false);
}
}
async function handleDelete(credentialId: string) {
if (!confirm('Diesen Passkey wirklich entfernen?')) return;
setError(null);
try {
const res = await fetch(`/api/passkey?id=${encodeURIComponent(credentialId)}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
await load();
} catch {
setError('Passkey konnte nicht entfernt werden.');
}
}
return (
<div className="passkeys">
<h3 className="passkeys-title">Passkeys</h3>
<p className="passkeys-hint">
Melde dich künftig per Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Das Passwort
bleibt als Alternative bestehen.
</p>
{loading ? (
<p className="passkeys-empty">Lädt</p>
) : passkeys.length === 0 ? (
<p className="passkeys-empty">Noch keine Passkeys registriert.</p>
) : (
<ul className="passkeys-list">
{passkeys.map((pk) => (
<li key={pk.credentialId} className="passkeys-item">
<div className="passkeys-item-info">
<div className="passkeys-item-label">{pk.label}</div>
<div className="passkeys-item-meta">
Erstellt {formatDate(pk.createdAt)} · Zuletzt {formatDate(pk.lastUsedAt)}
</div>
</div>
<button onClick={() => handleDelete(pk.credentialId)} className="passkeys-remove">
Entfernen
</button>
</li>
))}
</ul>
)}
<div className="passkeys-add">
<div className="passkeys-add-field">
<label className="passkeys-add-label">Bezeichnung (optional)</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="z. B. iPhone"
maxLength={80}
className="passkeys-input"
/>
</div>
<button onClick={handleRegister} disabled={busy} className="passkeys-add-btn">
{busy ? 'Läuft…' : 'Passkey hinzufügen'}
</button>
</div>
{error && <div className="login-error passkeys-error">{error}</div>}
</div>
);
}