Files
AirCardIOS/web/src/account/login-dialog.ts
T

408 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}