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
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user