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:
1 parent
d1d4d621a7
commit
10c55ca933
15 files changed
+1866
-10
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user