feat(account): Benutzername beim Registrieren, im Konto-Menü und als einmalige Abfrage

- Neu `account/profile.ts`: Muster, Sperrliste und Schlüssel (ein Test vergleicht
  sie mit `deploy/api/common.js`), Profile lesen (`getUsername`, `usernamesFor`
  gesammelt in einer Abfrage und zwischengespeichert, bei einem Fehler nur die
  bekannten Namen), `isUsernameTaken`, `saveUsername` (POST /api/profil,
  Zwischenspeicher + `onUsernameChange`), `myUsername`/`loadMyUsername` und der
  Merker `aircard.usernamePrompted`. `IDS.profiles`.
- Neu `account/username-dialog.ts`: Dialog „Benutzername wählen“ in den Modi
  prompt („Später“), required („Abbrechen“) und change (vorbelegt), Fehler am
  Feld, Busy-Sperre, höchstens ein Dialog zur Zeit; dazu `requireUsername()`.
- Registrieren: Pflichtfeld „Benutzername“ vor der E-Mail. Muster, Sperrliste und
  Verfügbarkeit werden vor dem Anlegen des Kontos geprüft (kein halbes Konto);
  danach wird der Name gespeichert, scheitert das, schließt der Login-Dialog und
  der Namensdialog fragt.
- Konto-Menü: „@name“ oben, die E-Mail klein darunter, Eintrag „Benutzername
  ändern“ bzw. „Benutzername wählen“.
- main.ts: Name nach Start und Anmeldung lesen; ohne Namen einmal pro Sitzung der
  Dialog „prompt“ (nicht, wenn der Name nicht lesbar ist).
- Texte in `en.ts`, Stile in `account.css`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 07:22:15 +00:00
1 parent 19c8151c7e
commit 3e4bc21f00
14 files changed
+2660 -40

No files matched your search

+97 -18
View File
@@ -1,9 +1,20 @@
// Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren und „Passwort vergessen“.
// Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren (mit Benutzernamen) und „Passwort vergessen“.
import './account.css';
import { el } from '../editor/dom';
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';
@@ -15,6 +26,8 @@ const TITLES: Record<LoginView, string> = {
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 };
@@ -29,6 +42,7 @@ type Panel = {
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;
@@ -90,7 +104,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
function field(
labelText: string,
name: string,
type: 'email' | 'password',
type: 'text' | 'email' | 'password',
autocomplete: string,
value = '',
describedBy?: string,
@@ -102,8 +116,9 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
autocomplete,
value,
required: true,
autocapitalize: type === 'email' ? 'none' : null,
spellcheck: type === 'email' ? 'false' : null,
// 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);
@@ -162,25 +177,35 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
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. */
function showFieldError(panel: Panel, input: HTMLInputElement, text: string): void {
showError(panel, text);
setFieldInvalid(input, ERROR_ID, true);
input.focus();
}
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. */
/**
* 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<void>,
action: () => Promise<HTMLElement | void>,
onError?: (err: unknown) => Node | undefined,
): Promise<void> {
const mine = session;
const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
let focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
setBusy(panel, true);
panel.errorEl.textContent = '';
panel.okEl.textContent = '';
try {
await action();
focused = (await action()) ?? focused;
} catch (err) {
if (mine !== session) return;
showError(panel, errorMessage(err, ctx), onError?.(err));
@@ -190,8 +215,51 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
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 saved = true;
try {
await saveUsername(name);
} catch {
saved = false;
}
finish(true);
if (!saved) {
usernameDialog({ mode: 'prompt' }).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', role: 'alert' });
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);
@@ -215,17 +283,28 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
}
if (forView === 'register') {
const username = field(t('Benutzername'), 'username', 'text', 'username', 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(email.label, password.label, hint, repeat.label, errorEl, okEl, submit(t('Registrieren')));
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
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; verglichen wird nie mit dem (übersetzten) Text.
// 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) {
@@ -236,10 +315,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
void run(
panel,
'register',
async () => {
await register(address, password.input.value);
finish(true);
},
() => registerWithName(panel, username.input, name, address, password.input.value),
(err) => {
if (errorType(err) !== 'user_already_exists') return undefined;
return linkButton(t('Anmelden'), () => showView('login'));
@@ -267,9 +343,11 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
/** 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.
// 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);
@@ -287,6 +365,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
if (pending) return pending;
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
draftEmail = '';
draftUsername = '';
busy = false;
pending = new Promise<boolean>((resolve) => {
settle = resolve;