feat(account): Login, Registrierung, Passwort zurücksetzen

- appwrite 22.4.1 (exakt gepinnt); Client mit Endpoint aus VITE_APPWRITE_ENDPOINT
  bzw. location.origin + /v1, Projekt aus VITE_APPWRITE_PROJECT_ID bzw. aircard
- auth.ts: getCurrentUser (401 -> null, ungültige Antwort -> Fehler), login, register,
  logout, Passwort-Link anfordern/abschließen, onAuthChange, validatePassword
- errors.ts: deutsche Meldungen nach type, dann code (Tabelle aus dem Plan)
- Login-Dialog (Anmelden/Registrieren/Passwort vergessen) als gemeinsames <dialog>,
  Konto-Menü im Seitenkopf, Seite /passwort-neu
- Tests: errors, auth, Dialog, Passwort-neu-Seite

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 08:54:10 +00:00
1 parent d1d4d621a7
commit 10c55ca933
15 files changed
+1866 -10

No files matched your search

+308
View File
@@ -0,0 +1,308 @@
// Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren und „Passwort vergessen“.
import './account.css';
import { el } from '../editor/dom';
import { login, register, requestPasswordReset, validatePassword } from './auth';
import { errorMessage, errorType, type ErrorContext } from './errors';
export type LoginView = 'login' | 'register' | 'forgot';
const TITLES: Record<LoginView, string> = {
login: 'Anmelden',
register: 'Registrieren',
forgot: 'Passwort vergessen',
};
const MISMATCH = 'Die Passwörter stimmen nicht überein.';
const RESET_SENT = 'Wir haben dir einen Link geschickt. Er gilt eine Stunde.';
type Field = { label: HTMLLabelElement; input: HTMLInputElement };
/** Alles, was eine Ansicht dem Rahmen übergibt: Formular, erstes Feld, Meldungen. */
type Panel = {
form: HTMLFormElement;
first: HTMLElement;
errorEl: HTMLElement;
okEl: HTMLElement;
};
function createLoginDialog(): (view: LoginView) => Promise<boolean> {
let view: LoginView = 'login';
let draftEmail = '';
let busy = false;
// Zählt jedes Öffnen/Schließen; eine Antwort, die nach dem Schließen eintrifft, wird verworfen.
let session = 0;
let opener: HTMLElement | null = null;
let settle: ((ok: boolean) => void) | null = null;
let pending: Promise<boolean> | null = null;
const closeButton = el(
'button',
{ type: 'button', class: 'auth-close', 'aria-label': 'Schließen' },
el('span', { 'aria-hidden': 'true' }, '×'),
);
const title = el('h2', { id: 'auth-title', class: 'auth-title' });
const content = el('div', { class: 'auth-content' });
const dialog = el(
'dialog',
{ class: 'auth-dialog', 'aria-labelledby': 'auth-title' },
el('div', { class: 'auth-card' }, closeButton, title, content),
);
document.body.append(dialog);
/** Beendet den Dialog genau einmal; Esc, Schließen-Knopf und Hintergrund laufen hier zusammen. */
function finish(ok: boolean): void {
const done = settle;
if (!done) return;
settle = null;
pending = null;
busy = false;
session += 1;
content.replaceChildren(); // keine Eingaben (Passwörter) im geschlossenen Dialog zurücklassen
if (dialog.open) dialog.close();
restoreFocus();
done(ok);
}
function restoreFocus(): void {
// Der „Anmelden“-Knopf im Konto-Menü wird nach dem Login ersetzt; dann geht der Fokus an dessen Nachfolger.
const target = opener?.isConnected ? opener : document.querySelector<HTMLElement>('#account-slot button');
opener = null;
target?.focus();
}
closeButton.addEventListener('click', () => finish(false));
// Esc schließt nativ; `close` kommt asynchron, daher nicht eingreifen, wenn der Dialog schon wieder offen ist.
dialog.addEventListener('close', () => {
if (!dialog.open) finish(false);
});
// Hintergrund = Klick auf das <dialog> selbst (die Karte füllt es ganz aus); Drücken und Loslassen müssen beide dort sein,
// sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog.
let pressedOnBackdrop = false;
dialog.addEventListener('mousedown', (event) => {
pressedOnBackdrop = event.target === dialog;
});
dialog.addEventListener('click', (event) => {
if (event.target === dialog && pressedOnBackdrop) finish(false);
pressedOnBackdrop = false;
});
function field(
labelText: string,
name: string,
type: 'email' | 'password',
autocomplete: string,
value = '',
describedBy?: string,
): Field {
const input = el('input', {
class: 'auth-input',
type,
name,
autocomplete,
value,
required: true,
autocapitalize: type === 'email' ? 'none' : null,
spellcheck: type === 'email' ? 'false' : null,
'aria-describedby': describedBy,
});
const label = el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, labelText), input);
return { label, input };
}
function linkButton(text: string, onClick: () => void): HTMLButtonElement {
const button = el('button', { type: 'button', class: 'auth-link' }, text);
button.addEventListener('click', () => {
if (!busy) onClick();
});
return button;
}
function buildTabs(): HTMLElement {
const select = (next: LoginView) => {
if (busy || next === view) return;
showView(next, 'tab');
};
const tabs = (['login', 'register'] as const).map((tabView) => {
const selected = view === tabView;
const tab = el(
'button',
{
type: 'button',
role: 'tab',
id: `auth-tab-${tabView}`,
class: 'auth-tab',
'aria-selected': String(selected),
'aria-controls': 'auth-panel',
tabindex: selected ? 0 : -1,
},
TITLES[tabView],
);
tab.addEventListener('click', () => select(tabView));
tab.addEventListener('keydown', (event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
select(tabView === 'login' ? 'register' : 'login');
});
return tab;
});
return el('div', { class: 'auth-tabs', role: 'tablist', 'aria-label': 'Konto' }, ...tabs);
}
/** Setzt oder sperrt alle Bedienelemente, solange eine Anfrage läuft; Eingabefelder bleiben fokussierbar. */
function setBusy(panel: Panel, value: boolean): void {
busy = value;
panel.form.setAttribute('aria-busy', String(value));
for (const input of panel.form.querySelectorAll('input')) input.readOnly = value;
for (const button of content.querySelectorAll('button')) button.disabled = value;
}
function showError(panel: Panel, text: string, extra?: Node): void {
panel.okEl.textContent = '';
panel.errorEl.replaceChildren(text, ...(extra ? [' ', extra] : []));
}
function showOk(panel: Panel, text: string): void {
panel.errorEl.textContent = '';
panel.okEl.textContent = text;
}
/** Führt eine Anfrage aus: sperrt das Formular, zeigt Fehler und gibt danach den Fokus zurück. */
async function run(
panel: Panel,
ctx: ErrorContext,
action: () => Promise<void>,
onError?: (err: unknown) => Node | undefined,
): Promise<void> {
const mine = session;
const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
setBusy(panel, true);
panel.errorEl.textContent = '';
panel.okEl.textContent = '';
try {
await action();
} catch (err) {
if (mine !== session) return;
showError(panel, errorMessage(err, ctx), onError?.(err));
}
if (mine !== session) return; // Dialog wurde inzwischen geschlossen (auch nach Erfolg)
setBusy(panel, false);
if (focused?.isConnected && dialog.contains(focused)) focused.focus();
}
function buildPanel(forView: LoginView): Panel {
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
const okEl = el('p', { class: 'auth-msg auth-msg-ok', role: 'status' });
const form = el('form', { class: 'auth-form' });
const submit = (text: string) => el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, text);
if (forView === 'login') {
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
const password = field('Passwort', 'password', 'password', 'current-password');
const forgot = linkButton('Passwort vergessen?', () => showView('forgot'));
form.append(email.label, password.label, errorEl, okEl, submit('Anmelden'), el('p', { class: 'auth-aside' }, forgot));
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
form.addEventListener('submit', (event) => {
event.preventDefault();
if (busy) return;
const address = email.input.value.trim();
void run(panel, 'login', async () => {
await login(address, password.input.value);
finish(true);
});
});
return panel;
}
if (forView === 'register') {
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
const password = field('Passwort', 'password', 'password', 'new-password', '', 'auth-pw-hint');
const repeat = field('Passwort wiederholen', 'password-repeat', 'password', 'new-password');
const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, 'Mindestens 8 Zeichen.');
form.append(email.label, password.label, hint, repeat.label, errorEl, okEl, submit('Registrieren'));
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
form.addEventListener('submit', (event) => {
event.preventDefault();
if (busy) return;
const address = email.input.value.trim();
const problem = password.input.value !== repeat.input.value ? MISMATCH : validatePassword(password.input.value);
if (problem) {
showError(panel, problem);
(problem === MISMATCH ? repeat : password).input.focus();
return;
}
void run(
panel,
'register',
async () => {
await register(address, password.input.value);
finish(true);
},
(err) => {
if (errorType(err) !== 'user_already_exists') return undefined;
return linkButton('Anmelden', () => showView('login'));
},
);
});
return panel;
}
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
const back = linkButton('Zurück zur Anmeldung', () => showView('login'));
form.append(email.label, errorEl, okEl, submit('Link senden'), el('p', { class: 'auth-aside' }, back));
const panel: Panel = { form, first: email.input, errorEl, okEl };
form.addEventListener('submit', (event) => {
event.preventDefault();
if (busy) return;
const address = email.input.value.trim();
void run(panel, 'recover', async () => {
await requestPasswordReset(address);
showOk(panel, RESET_SENT);
});
});
return panel;
}
/** Zeigt eine Ansicht; `focus` bestimmt, ob danach das erste Feld oder der gewählte Reiter den Fokus bekommt. */
function showView(next: LoginView, focus: 'input' | 'tab' = 'input'): void {
// Die eingegebene E-Mail wandert beim Wechsel in die neue Ansicht mit; Passwörter nie.
const typed = content.querySelector<HTMLInputElement>('input[name="email"]');
if (typed) draftEmail = typed.value.trim();
view = next;
title.textContent = TITLES[next];
const panel = buildPanel(next);
if (next === 'forgot') {
content.replaceChildren(panel.form);
} else {
const body = el('div', { id: 'auth-panel', role: 'tabpanel', 'aria-labelledby': `auth-tab-${next}` }, panel.form);
content.replaceChildren(buildTabs(), body);
}
if (focus === 'tab') content.querySelector<HTMLElement>(`#auth-tab-${next}`)?.focus();
else panel.first.focus();
}
return (initial) => {
if (pending) return pending;
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
draftEmail = '';
busy = false;
pending = new Promise<boolean>((resolve) => {
settle = resolve;
});
showView(initial);
dialog.showModal();
// showModal fokussiert das erste bedienbare Element (den Schließen-Knopf); wir wollen das erste Eingabefeld.
content.querySelector<HTMLElement>('input')?.focus();
return pending;
};
}
let open: ((view: LoginView) => Promise<boolean>) | null = null;
/**
* Öffnet den Login-Dialog (beim ersten Aufruf erzeugt). Gibt true zurück, sobald sich der Nutzer angemeldet
* oder registriert hat, und false, wenn er den Dialog schließt. Gleichzeitige Aufrufe teilen sich ein Ergebnis.
*/
export function requireLogin(view: LoginView = 'login'): Promise<boolean> {
open ??= createLoginDialog();
return open(view);
}