da79543d54
Zusätzlich zu Benutzername/Passwort ist die Anmeldung per Passkey möglich (Fingerabdruck, Gesichtserkennung, Geräte-PIN). Das Passwort bleibt als Alternative bestehen. - lib/webauthn.ts kapselt @simplewebauthn/server, RP-Config über RP_ID/RP_ORIGIN/RP_NAME, Challenge in kurzlebigem httpOnly-Cookie - lib/passkeys.ts speichert Credentials je Benutzer in der MySQL-Tabelle passkeys und legt sie beim ersten Zugriff an - API unter app/api/passkey/: register (Session nötig), authenticate (öffentlich, in proxy.ts von der Session-Pflicht ausgenommen), GET/DELETE zum Verwalten - Neuer Tab "Einstellungen" zum Registrieren und Entfernen von Passkeys - Button "Mit Passkey anmelden" auf der Login-Seite Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
152 lines
5.0 KiB
TypeScript
152 lines
5.0 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="max-w-md">
|
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Passkeys</h3>
|
|
<p className="text-xs text-gray-500 mb-3">
|
|
Melde dich künftig per Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Das Passwort
|
|
bleibt als Alternative bestehen.
|
|
</p>
|
|
|
|
{loading ? (
|
|
<p className="text-sm text-gray-500">Lädt…</p>
|
|
) : passkeys.length === 0 ? (
|
|
<p className="text-sm text-gray-500 mb-3">Noch keine Passkeys registriert.</p>
|
|
) : (
|
|
<ul className="space-y-2 mb-4">
|
|
{passkeys.map((pk) => (
|
|
<li
|
|
key={pk.credentialId}
|
|
className="flex items-center justify-between gap-3 border-2 border-gray-300 rounded-lg px-3 py-2 bg-white"
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-gray-900 truncate">{pk.label}</div>
|
|
<div className="text-xs text-gray-500">
|
|
Erstellt {formatDate(pk.createdAt)} · Zuletzt {formatDate(pk.lastUsedAt)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => handleDelete(pk.credentialId)}
|
|
className="text-xs px-2 py-1 text-red-600 hover:text-red-800 shrink-0"
|
|
>
|
|
Entfernen
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
<div className="flex items-end gap-2">
|
|
<div className="flex-1">
|
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
Bezeichnung (optional)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={label}
|
|
onChange={(e) => setLabel(e.target.value)}
|
|
placeholder="z. B. iPhone"
|
|
maxLength={80}
|
|
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-blue-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleRegister}
|
|
disabled={busy}
|
|
className="px-4 py-2 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium rounded-lg transition-colors disabled:opacity-50 text-sm shrink-0"
|
|
>
|
|
{busy ? 'Läuft…' : 'Passkey hinzufügen'}
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mt-3 bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|