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

+232 -6
View File
@@ -1,12 +1,19 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
type TestUser = { $id: string; email: string };
// Der Einstieg läuft mit echtem Router, echter Kopfzeile, Banner, Fußzeile und Konto-Menü; Seiten, Anmeldung und Appwrite sind Attrappen.
// Vom Benutzernamen läuft das Echte (Merker in sessionStorage); ersetzt sind nur das Lesen des Profils und der Dialog.
const mocks = vi.hoisted(() => {
const page = () => vi.fn((..._args: unknown[]) => vi.fn()); // baut eine Seite auf und gibt deren Aufräum-Funktion zurück
return {
setLocale: vi.fn(),
getCurrentUser: vi.fn(),
authListeners: new Set<(user: null) => void>(),
/** Der angemeldete Nutzer, wie `currentUser`/`currentUserId` ihn nennen. */
user: null as TestUser | null,
authListeners: new Set<(user: TestUser | null) => void>(),
loadMyUsername: vi.fn(),
usernameDialog: vi.fn(),
pages: { editor: page(), cards: page(), community: page(), reset: page(), home: page() },
};
});
@@ -14,14 +21,16 @@ const mocks = vi.hoisted(() => {
vi.mock('./account/appwrite', () => ({ client: { setLocale: mocks.setLocale } }));
vi.mock('./account/auth', () => ({
getCurrentUser: mocks.getCurrentUser,
currentUser: () => null,
currentUser: () => mocks.user,
currentUserId: () => mocks.user?.$id ?? null,
logout: vi.fn(),
onAuthChange: (fn: (user: null) => void) => {
onAuthChange: (fn: (user: TestUser | null) => void) => {
mocks.authListeners.add(fn);
return () => mocks.authListeners.delete(fn);
},
}));
vi.mock('./account/login-dialog', () => ({ requireLogin: vi.fn() }));
vi.mock('./account/username-dialog', () => ({ usernameDialog: mocks.usernameDialog }));
vi.mock('./editor/auth-reset', () => ({ bindEditorToAuth: vi.fn() }));
vi.mock('./editor/editor-page', () => ({ mountEditor: mocks.pages.editor }));
vi.mock('./pages/cards-page', () => ({ mountCardsPage: mocks.pages.cards }));
@@ -32,6 +41,9 @@ vi.mock('./pages/home-page', () => ({ mountHomePage: mocks.pages.home }));
const DE_FOOTER = 'Inoffizielle Community-Seite für die App AirCard-iOS';
const EN_FOOTER = 'Unofficial community site for the AirCard-iOS app';
/** Abonnenten von onAuthChange nach dem Start: das Konto-Menü und die Abfrage des Benutzernamens (main.ts); ein Sprachwechsel ändert die Zahl nicht. */
const AUTH_LISTENERS = 2;
const slot = () => document.getElementById('account-slot') as HTMLElement;
const langButton = () => document.querySelector<HTMLButtonElement>('button.lang-btn')!;
const navTexts = () => [...document.querySelectorAll('nav.app-nav a[data-link]')].map((a) => a.textContent);
@@ -41,6 +53,12 @@ const signIn = (label = 'Anmelden') => [...slot().querySelectorAll('button')].fi
/** Startet main.ts frisch (es läuft beim Import) auf der Adresse `path`; `storedLang` ist die gespeicherte Sprachwahl (null: keine). */
async function start(path = '/', storedLang: string | null = 'de') {
vi.resetModules();
// Das Profil: nur das Lesen im Netz ist ersetzt. Neu nach jedem Zurücksetzen, damit jede Kopie von main.ts ihren eigenen Merker
// „schon gefragt“ im Speicher hat (ein `vi.mock` bliebe bei der ersten Kopie von profile.ts stehen).
vi.doMock('./account/profile', async () => ({
...(await vi.importActual<typeof import('./account/profile')>('./account/profile')),
loadMyUsername: mocks.loadMyUsername,
}));
localStorage.clear();
if (storedLang) localStorage.setItem('aircard.lang', storedLang);
history.replaceState(null, '', path);
@@ -55,7 +73,11 @@ async function start(path = '/', storedLang: string | null = 'de') {
beforeEach(() => {
mocks.setLocale.mockReset();
mocks.getCurrentUser.mockReset().mockResolvedValue(null); // Gast: der Anmeldestatus ist „abgemeldet“
mocks.user = null;
mocks.authListeners.clear();
mocks.loadMyUsername.mockReset().mockResolvedValue(null); // das Konto hat keinen Namen
mocks.usernameDialog.mockReset().mockResolvedValue(null); // „Später“
sessionStorage.clear();
for (const page of Object.values(mocks.pages)) page.mockReset();
});
@@ -170,12 +192,12 @@ describe('Sprachwechsel', () => {
it('das Konto-Menü wird neu gebaut und zeigt sich sofort, wenn der Anmeldestatus schon bekannt ist', async () => {
await start('/');
await vi.waitFor(() => expect(signIn()).toBeDefined());
expect(mocks.authListeners.size).toBe(1);
expect(mocks.authListeners.size).toBe(AUTH_LISTENERS);
langButton().click();
expect(signIn('Sign in')).toBeDefined(); // kein leerer Platz bis zur nächsten Änderung, jetzt auf Englisch
expect(mocks.authListeners.size).toBe(1); // das alte Menü hat sich abgemeldet
expect(mocks.authListeners.size).toBe(AUTH_LISTENERS); // das alte Menü hat sich abgemeldet
expect(mocks.getCurrentUser).toHaveBeenCalledTimes(1); // der Status wird nicht noch einmal geholt
});
@@ -187,7 +209,7 @@ describe('Sprachwechsel', () => {
langButton().click();
expect(slot().childElementCount).toBe(0);
expect(mocks.authListeners.size).toBe(1);
expect(mocks.authListeners.size).toBe(AUTH_LISTENERS);
answer(null);
await vi.waitFor(() => expect(signIn('Sign in')).toBeDefined());
@@ -214,3 +236,207 @@ describe('Sprachwechsel', () => {
expect(document.activeElement).toBe(document.body);
});
});
describe('Benutzername abfragen (Spec § 5)', () => {
const kenso: TestUser = { $id: 'u1', email: 'kenso@example.com' };
const PROMPTED_KEY = 'aircard.usernamePrompted';
/** Das Konto ist schon beim Start angemeldet (Sitzung im Browser). */
const sessionAtStart = (user: TestUser = kenso) => {
mocks.user = user;
mocks.getCurrentUser.mockResolvedValue(user);
};
/** Der Nutzer meldet sich an: erst steht er fest, dann hören es alle Abonnenten (wie auth.ts). */
const userSignsIn = (user: TestUser = kenso) => {
mocks.user = user;
for (const fn of [...mocks.authListeners]) fn(user);
};
const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
it('Start mit Sitzung, Konto ohne Namen: der Dialog „prompt“ erscheint genau einmal; der Merker liegt in sessionStorage', async () => {
sessionAtStart();
await start('/');
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
expect(mocks.usernameDialog).toHaveBeenCalledWith({ mode: 'prompt' });
expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1');
await settle();
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
});
it('der Merker steht schon, wenn der Dialog erscheint (ein zweiter Auslöser, solange er offen ist, fragt nicht noch einmal)', async () => {
let flagWhenShown: string | null | undefined;
let close!: () => void;
mocks.usernameDialog.mockImplementation(() => {
flagWhenShown = sessionStorage.getItem(PROMPTED_KEY);
return new Promise<null>((resolve) => (close = () => resolve(null)));
});
sessionAtStart();
await start('/');
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
expect(flagWhenShown).toBe('1');
userSignsIn(); // während der Dialog offen ist
await settle();
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
close();
await settle();
userSignsIn(); // auch nach „Später“
await settle();
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
});
it('Start mit Sitzung, Konto mit Namen: kein Dialog, der Merker bleibt leer', async () => {
mocks.loadMyUsername.mockResolvedValue('Kenso');
sessionAtStart();
await start('/');
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalled());
await settle();
expect(mocks.usernameDialog).not.toHaveBeenCalled();
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
});
it('Gast: nichts wird gelesen und nichts gefragt', async () => {
await start('/');
await vi.waitFor(() => expect(signIn()).toBeDefined());
await settle();
expect(mocks.loadMyUsername).not.toHaveBeenCalled();
expect(mocks.usernameDialog).not.toHaveBeenCalled();
});
it('Anmeldung nach dem Start, Konto ohne Namen: „prompt“ genau einmal; eine weitere Anmeldung in derselben Sitzung fragt nicht noch einmal', async () => {
await start('/');
await vi.waitFor(() => expect(signIn()).toBeDefined());
userSignsIn();
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
expect(mocks.usernameDialog).toHaveBeenCalledWith({ mode: 'prompt' });
userSignsIn({ $id: 'u1', email: 'kenso@example.com' }); // z. B. ein Labelwechsel des Kontos
await settle();
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
});
it('Anmeldung, Konto mit Namen: kein Dialog', async () => {
mocks.loadMyUsername.mockResolvedValue('Kenso');
await start('/');
userSignsIn();
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1));
await settle();
expect(mocks.usernameDialog).not.toHaveBeenCalled();
});
it('Abmelden löst nichts aus', async () => {
mocks.loadMyUsername.mockResolvedValue('Kenso');
sessionAtStart();
await start('/');
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1));
mocks.user = null;
for (const fn of [...mocks.authListeners]) fn(null);
await settle();
expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1);
expect(mocks.usernameDialog).not.toHaveBeenCalled();
});
it('Start und Anmeldemeldung für dasselbe Konto zugleich (wie im echten Start): ein Lesen, ein Dialog', async () => {
mocks.loadMyUsername.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(null), 5)));
mocks.getCurrentUser.mockImplementation(async () => {
userSignsIn(); // getCurrentUser meldet den neuen Nutzer an alle Abonnenten, bevor es ihn zurückgibt
return kenso;
});
await start('/');
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
await settle();
expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1);
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
});
it('Neuladen in derselben Sitzung: der Merker aus sessionStorage hält die Abfrage zurück, der Name wird trotzdem gelesen', async () => {
sessionAtStart();
await start('/');
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
mocks.authListeners.clear(); // die erste Seite ist weg
mocks.loadMyUsername.mockClear();
await start('/');
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1));
await settle();
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
});
it('eine neue Sitzung (anderer Tab) fragt wieder', async () => {
sessionAtStart();
await start('/');
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
sessionStorage.clear(); // neuer Tab = neue sessionStorage
mocks.authListeners.clear();
await start('/');
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(2));
});
it('der Name lässt sich nicht lesen: kein Dialog (das Konto könnte einen haben), kein Merker, kein Absturz', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mocks.loadMyUsername.mockRejectedValue(new TypeError('Failed to fetch'));
sessionAtStart();
await start('/');
await vi.waitFor(() => expect(error).toHaveBeenCalled());
expect(mocks.usernameDialog).not.toHaveBeenCalled();
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
expect(slot().childElementCount).toBeGreaterThan(0); // die Seite steht
// Beim nächsten Versuch (neue Anmeldemeldung) wird gefragt, wenn der Name lesbar ist.
mocks.loadMyUsername.mockResolvedValue(null);
userSignsIn();
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
error.mockRestore();
});
it('scheitert der Dialog beim Öffnen, bleibt es bei einer Meldung in der Konsole', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt'));
sessionAtStart();
await start('/');
await vi.waitFor(() => expect(error).toHaveBeenCalled());
expect(slot().childElementCount).toBeGreaterThan(0);
error.mockRestore();
});
it('wird der Nutzer abgemeldet, während sein Name gelesen wird, erscheint kein Dialog', async () => {
let answer!: (name: string | null) => void;
mocks.loadMyUsername.mockReturnValue(new Promise((resolve) => (answer = resolve)));
await start('/');
userSignsIn();
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1));
mocks.user = null; // abgemeldet, bevor die Antwort da ist
answer(null);
await settle();
expect(mocks.usernameDialog).not.toHaveBeenCalled();
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
});
it('wechselt das Konto, während gelesen wird, wird das andere Konto gefragt, nicht das alte', async () => {
const mia: TestUser = { $id: 'u2', email: 'mia@example.com' };
const answers: Array<(name: string | null) => void> = [];
mocks.loadMyUsername.mockImplementation(() => new Promise((resolve) => answers.push(resolve)));
await start('/');
userSignsIn(kenso);
await vi.waitFor(() => expect(answers).toHaveLength(1));
userSignsIn(mia); // Kontowechsel, bevor die erste Antwort da ist
await vi.waitFor(() => expect(answers).toHaveLength(2));
answers[0](null); // die Antwort für das alte Konto: ohne Wirkung
await settle();
expect(mocks.usernameDialog).not.toHaveBeenCalled();
answers[1](null);
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
});
it('ist sessionStorage gesperrt, wirft nichts, und gefragt wird einmal pro Seitenaufruf', async () => {
const denied = () => {
throw new DOMException('blockiert', 'SecurityError');
};
vi.stubGlobal('sessionStorage', { getItem: denied, setItem: denied, removeItem: denied });
sessionAtStart();
await start('/');
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
userSignsIn();
await settle();
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
});
});