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

+168 -1
View File
@@ -7,6 +7,10 @@ const mocks = vi.hoisted(() => ({
listeners: new Set<(user: TestUser | null) => void>(),
logout: vi.fn(),
requireLogin: vi.fn(),
/** Der Benutzername des angemeldeten Kontos laut Zwischenspeicher (null: keiner oder unbekannt). */
name: null as string | null,
nameListeners: new Set<(userId: string, username: string) => void>(),
usernameDialog: vi.fn(),
}));
vi.mock('./auth', () => ({
@@ -18,6 +22,14 @@ vi.mock('./auth', () => ({
},
}));
vi.mock('./login-dialog', () => ({ requireLogin: mocks.requireLogin }));
vi.mock('./profile', () => ({
myUsername: () => mocks.name,
onUsernameChange: (fn: (userId: string, username: string) => void) => {
mocks.nameListeners.add(fn);
return () => mocks.nameListeners.delete(fn);
},
}));
vi.mock('./username-dialog', () => ({ usernameDialog: mocks.usernameDialog }));
import { mountAccountMenu, type AccountMenu } from './account-menu';
@@ -33,11 +45,21 @@ function setUser(user: TestUser | null) {
for (const fn of [...mocks.listeners]) fn(user);
}
/** Der Name des Kontos ändert sich (eigene Änderung oder nach dem Lesen), wie `onUsernameChange` es meldet. */
function setName(userId: string, name: string) {
mocks.name = name;
for (const fn of [...mocks.nameListeners]) fn(userId, name);
}
beforeEach(() => {
mocks.user = null;
mocks.name = null;
mocks.listeners.clear();
mocks.nameListeners.clear();
mocks.logout.mockReset();
mocks.requireLogin.mockReset();
mocks.usernameDialog.mockReset();
mocks.usernameDialog.mockResolvedValue(null);
document.body.innerHTML = '<div id="account-slot"></div>';
slot = document.getElementById('account-slot') as HTMLElement;
menu = mountAccountMenu(slot);
@@ -130,11 +152,156 @@ describe('Konto-Menü', () => {
expect(buttonByText('Anmelden')).toBeDefined();
});
it('dispose leert den Slot und meldet den Listener ab', () => {
it('dispose leert den Slot und meldet die Listener ab', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
menu.dispose();
expect(slot.childElementCount).toBe(0);
expect(mocks.listeners.size).toBe(0);
expect(mocks.nameListeners.size).toBe(0);
});
});
describe('Konto-Menü: Benutzername', () => {
const signedIn = () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
};
const head = () => panel()!.querySelector<HTMLElement>('.account-head')!;
const nameLine = () => panel()!.querySelector('.account-name');
const items = () => [...panel()!.querySelectorAll('.account-item')].map((item) => item.textContent);
it('mit Namen steht „@name“ oben und die E-Mail klein darunter', () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
expect(nameLine()?.textContent).toBe('@Kenso');
expect(panel()!.querySelector('.account-email')?.textContent).toBe('kenso@example.com');
expect([...head().children].map((child) => child.className)).toEqual(['account-name', 'account-email']); // Name zuerst
expect(head().parentElement).toBe(panel());
expect(head().nextElementSibling?.textContent).toBe('Meine Karten'); // die Kopfzeile steht vor den Einträgen
});
it('der Avatar bleibt bei der Initiale der E-Mail', () => {
signedIn();
mocks.name = 'Zebra';
menu.ready();
expect(avatar()?.textContent).toBe('K');
});
it('ohne Namen steht nur die E-Mail oben (wie bisher)', () => {
signedIn();
menu.ready();
expect(nameLine()).toBeNull();
expect(head().textContent).toBe('kenso@example.com');
});
it('Name und E-Mail erscheinen als Text, nie als HTML', () => {
mocks.user = { $id: 'u1', email: '<i>x</i>@example.com' };
mocks.name = '<b>Kenso</b>';
menu.ready();
expect(head().querySelector('b, i')).toBeNull();
expect(nameLine()?.textContent).toBe('@<b>Kenso</b>');
expect(nameLine()?.getAttribute('title')).toBe('@<b>Kenso</b>');
});
it('Eintrag „Benutzername ändern“ mit Namen, „Benutzername wählen“ ohne – zwischen „Meine Karten“ und „Abmelden“', () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
expect(items()).toEqual(['Meine Karten', 'Benutzername ändern', 'Abmelden']);
setUser(null);
mocks.name = null;
setUser({ $id: 'u1', email: 'kenso@example.com' });
expect(items()).toEqual(['Meine Karten', 'Benutzername wählen', 'Abmelden']);
});
it('der Eintrag öffnet den Dialog im Modus „change“ und schließt das Menü', () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
avatar()!.click();
buttonByText('Benutzername ändern')!.click();
expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'change' });
expect(panel()?.hidden).toBe(true);
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
});
it('ohne Namen öffnet „Benutzername wählen“ denselben Dialog', () => {
signedIn();
menu.ready();
buttonByText('Benutzername wählen')!.click();
expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'change' });
});
it('nach dem Dialog geht der Fokus zurück an den Avatar (der Eintrag im geschlossenen Menü kann ihn nicht halten)', async () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
avatar()!.click();
buttonByText('Benutzername ändern')!.click();
await vi.waitFor(() => expect(document.activeElement).toBe(avatar()));
});
it('ändert sich der Name (Speichern im Dialog), zeigt das Menü sofort den neuen', () => {
signedIn();
mocks.name = 'Alt';
menu.ready();
expect(nameLine()?.textContent).toBe('@Alt');
setName('u1', 'Neu');
expect(nameLine()?.textContent).toBe('@Neu');
expect(items()).toContain('Benutzername ändern');
});
it('ein erst später gelesener Name erscheint ohne weiteres Zutun (Menü steht schon, der Name kommt nach)', () => {
signedIn();
menu.ready();
expect(nameLine()).toBeNull();
expect(buttonByText('Benutzername wählen')).toBeDefined();
setName('u1', 'Kenso');
expect(nameLine()?.textContent).toBe('@Kenso');
expect(buttonByText('Benutzername ändern')).toBeDefined();
});
it('nach dem Abmelden ist das Menü unabhängig vom Namen wieder „Anmelden“', () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
setUser(null);
expect(slot.querySelector('.account-name')).toBeNull();
expect(buttonByText('Anmelden')).toBeDefined();
});
it('vor ready() und nach dispose zeichnet auch eine Namensänderung nichts', () => {
signedIn();
setName('u1', 'Kenso');
expect(slot.childElementCount).toBe(0);
menu.ready();
menu.dispose();
setName('u1', 'Neu');
expect(slot.childElementCount).toBe(0);
});
it('lässt sich der Dialog nicht öffnen, bleibt es bei einer Meldung in der Konsole (kein unbehandelter Fehler), der Fokus geht trotzdem zurück', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt'));
signedIn();
menu.ready();
avatar()!.click();
buttonByText('Benutzername wählen')!.click();
await vi.waitFor(() => expect(error).toHaveBeenCalled());
await vi.waitFor(() => expect(document.activeElement).toBe(avatar()));
error.mockRestore();
});
it('auf Englisch: Einträge in der Sprache der Seite', async () => {
(await import('../i18n/i18n')).setLang('en', { persist: false });
signedIn();
mocks.name = 'Kenso';
menu.ready();
expect(items()).toEqual(['My cards', 'Change username', 'Sign out']);
expect(nameLine()?.textContent).toBe('@Kenso');
mocks.name = null;
setUser({ $id: 'u1', email: 'kenso@example.com' });
expect(items()).toEqual(['My cards', 'Choose a username', 'Sign out']);
});
});