// Login-Dialog: ein gemeinsames mit Anmelden, Registrieren (mit Benutzernamen) und „Passwort vergessen“. import './account.css'; import { el, setFieldInvalid } from '../editor/dom'; import { API_MESSAGES } from '../i18n/api-messages'; import { msg, t } from '../i18n/i18n'; import { login, register, requestPasswordReset, validatePassword } from './auth'; import { errorMessage, errorType, type ErrorContext } from './errors'; import { USERNAME_HINT, clearUsernamePrompted, isUsernameTaken, markUsernamePrompted, saveUsername, usernamePrompted, validateUsername, } from './profile'; import { usernameDialog } from './username-dialog'; export type LoginView = 'login' | 'register' | 'forgot'; const TITLES: Record = { login: msg('Anmelden'), register: msg('Registrieren'), forgot: msg('Passwort vergessen'), }; const MISMATCH = msg('Die Passwörter stimmen nicht überein.'); const RESET_SENT = msg('Wir haben dir einen Link geschickt. Er gilt eine Stunde.'); /** Die Meldungszeile des Dialogs: ein Feld mit Fehler verweist mit `aria-describedby` auf sie. */ const ERROR_ID = 'auth-error'; 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 { let view: LoginView = 'login'; let draftEmail = ''; let draftUsername = ''; 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 | null = null; const closeButton = el( 'button', { type: 'button', class: 'auth-close', 'aria-label': t('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('#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 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: 'text' | 'email' | 'password', autocomplete: string, value = '', describedBy?: string, ): Field { const input = el('input', { class: 'auth-input', type, name, autocomplete, value, required: true, // E-Mail-Adresse und Benutzername: keine automatische Großschreibung, keine Rechtschreibprüfung autocapitalize: type === 'password' ? null : 'none', spellcheck: type === 'password' ? null : 'false', '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, }, t(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': t('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] : [])); } /** * Ein Fehler am Eingabefeld, das ihn verursacht: der Text steht in der Meldungszeile, das Feld gilt als ungültig und bekommt den Fokus. * Die Meldungszeile steht unter allen Feldern, bei einem Fehler am ersten (dem Namen) kann sie auf einem kleinen Bildschirm außerhalb * liegen: sie wird ins Sichtbare gerückt, nur so weit wie nötig. */ function showFieldError(panel: Panel, input: HTMLInputElement, text: string): void { showError(panel, text); setFieldInvalid(input, ERROR_ID, true); input.focus(); panel.errorEl.scrollIntoView({ block: 'nearest' }); } 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: an das Feld, das die Aktion nennt * (sie hat dort einen Fehler gezeigt), sonst dorthin, wo er vorher war. */ async function run( panel: Panel, ctx: ErrorContext, action: () => Promise, onError?: (err: unknown) => Node | undefined, ): Promise { const mine = session; let focused = document.activeElement instanceof HTMLElement ? document.activeElement : null; setBusy(panel, true); panel.errorEl.textContent = ''; panel.okEl.textContent = ''; try { focused = (await action()) ?? focused; } 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(); } /** * Registrieren mit Benutzernamen (Spec § 5): erst die Verfügbarkeit des Namens, dann Konto anlegen und anmelden, zuletzt den Namen speichern. * Ist der Name vergeben, steht der Fehler am Feld, bevor ein Konto entsteht (sonst bliebe ein Konto ohne Namen zurück). Scheitert nur das * Speichern (Rennen um den Namen, Netz weg), ist man trotzdem angemeldet: der Dialog schließt, und der Namensdialog fragt noch einmal. */ async function registerWithName( panel: Panel, nameInput: HTMLInputElement, name: string, address: string, password: string, ): Promise { const mine = session; const taken = await isUsernameTaken(name); if (mine !== session) return; // der Dialog wurde inzwischen geschlossen: nichts mehr anlegen, nichts mehr zeigen if (taken) { showFieldError(panel, nameInput, t(API_MESSAGES.usernameTaken)); return nameInput; } // Schon die Anmeldung löst in main.ts die einmalige Abfrage nach dem Namen aus; hier wurde er gerade erst eingegeben. Darum steht der // Merker „in dieser Sitzung schon gefragt“ vor dem Anlegen. Scheitert es, nimmt der Dialog ihn wieder weg (wer sich danach anmeldet, wird gefragt). const asked = usernamePrompted(); markUsernamePrompted(); try { await register(address, password); } catch (err) { if (!asked) clearUsernamePrompted(); throw err; } let failure: unknown; let saved = true; try { await saveUsername(name); } catch (err) { saved = false; failure = err; } finish(true); if (!saved) { // Der Namensdialog beginnt mit dem getippten Namen und nennt den Grund (vergeben, kein Netz …), statt leer und stumm zu erscheinen. usernameDialog({ mode: 'prompt', initial: name, failure }).catch((err: unknown) => { console.error('Der Dialog „Benutzername“ konnte nicht geöffnet werden', err); }); } } function buildPanel(forView: LoginView): Panel { const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, 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(t('E-Mail'), 'email', 'email', 'email', draftEmail); const password = field(t('Passwort'), 'password', 'password', 'current-password'); const forgot = linkButton(t('Passwort vergessen?'), () => showView('forgot')); form.append(email.label, password.label, errorEl, okEl, submit(t('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') { // `nickname`, nicht `username`: angemeldet wird per E-Mail, und ein Passwortmanager soll das E-Mail-Feld als Anmeldenamen behalten. const username = field(t('Benutzername'), 'username', 'text', 'nickname', draftUsername, 'auth-name-hint'); const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail); const password = field(t('Passwort'), 'password', 'password', 'new-password', '', 'auth-pw-hint'); const repeat = field(t('Passwort wiederholen'), 'password-repeat', 'password', 'new-password'); const nameHint = el('p', { class: 'auth-hint', id: 'auth-name-hint' }, t(USERNAME_HINT)); const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, t('Mindestens 8 Zeichen.')); form.append(username.label, nameHint, email.label, password.label, hint, repeat.label, errorEl, okEl, submit(t('Registrieren'))); // Der Fokus geht an das erste Feld, das noch leer ist. const first = !draftUsername ? username.input : !draftEmail ? email.input : password.input; const panel: Panel = { form, first, errorEl, okEl }; form.addEventListener('submit', (event) => { event.preventDefault(); if (busy) return; setFieldInvalid(username.input, ERROR_ID, false); const name = username.input.value.trim(); // der Dienst trimmt nicht const address = email.input.value.trim(); // Der Fehler liegt am Feld, das ihn verursacht (in der Reihenfolge der Felder); verglichen wird nie mit dem (übersetzten) Text. const nameProblem = validateUsername(name); if (nameProblem) { showFieldError(panel, username.input, nameProblem); return; } const mismatch = password.input.value !== repeat.input.value; const problem = mismatch ? t(MISMATCH) : validatePassword(password.input.value); if (problem) { showError(panel, problem); (mismatch ? repeat : password).input.focus(); return; } void run( panel, 'register', () => registerWithName(panel, username.input, name, address, password.input.value), (err) => { if (errorType(err) !== 'user_already_exists') return undefined; return linkButton(t('Anmelden'), () => showView('login')); }, ); }); return panel; } const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail); const back = linkButton(t('Zurück zur Anmeldung'), () => showView('login')); form.append(email.label, errorEl, okEl, submit(t('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, t(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 und der Benutzername wandern beim Wechsel in die neue Ansicht mit; Passwörter nie. const typed = content.querySelector('input[name="email"]'); if (typed) draftEmail = typed.value.trim(); const typedName = content.querySelector('input[name="username"]'); if (typedName) draftUsername = typedName.value.trim(); view = next; title.textContent = t(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(`#auth-tab-${next}`)?.focus(); else panel.first.focus(); } return (initial) => { if (pending) return pending; opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; draftEmail = ''; draftUsername = ''; busy = false; pending = new Promise((resolve) => { settle = resolve; }); // Der Dialog bleibt zwischen den Aufrufen im DOM; die Sprache der Seite kann sich seit dem Bauen geändert haben. closeButton.setAttribute('aria-label', t('Schließen')); showView(initial); try { dialog.showModal(); } catch (err) { // Sonst bliebe `pending` für alle späteren Aufrufe ein totes Promise. finish(false); throw err; } // showModal fokussiert das erste bedienbare Element (den Schließen-Knopf); wir wollen das erste Eingabefeld. content.querySelector('input')?.focus(); return pending; }; } let open: ((view: LoginView) => Promise) | 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 { open ??= createLoginDialog(); return open(view); }