408 lines
16 KiB
TypeScript
408 lines
16 KiB
TypeScript
// Login-Dialog: ein gemeinsames <dialog> 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<LoginView, string> = {
|
||
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<boolean> {
|
||
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<boolean> | 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<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: '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<HTMLElement | void>,
|
||
onError?: (err: unknown) => Node | undefined,
|
||
): Promise<void> {
|
||
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<HTMLElement | void> {
|
||
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<HTMLInputElement>('input[name="email"]');
|
||
if (typed) draftEmail = typed.value.trim();
|
||
const typedName = content.querySelector<HTMLInputElement>('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<HTMLElement>(`#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<boolean>((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<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);
|
||
}
|