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:
1 parent
19c8151c7e
commit
3e4bc21f00
14 files changed
+2660
-40
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user