diff --git a/web/src/account/account-menu.test.ts b/web/src/account/account-menu.test.ts
index c14e0a5..f915684 100644
--- a/web/src/account/account-menu.test.ts
+++ b/web/src/account/account-menu.test.ts
@@ -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 = '
';
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('.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: 'x@example.com' };
+ mocks.name = 'Kenso';
+ menu.ready();
+ expect(head().querySelector('b, i')).toBeNull();
+ expect(nameLine()?.textContent).toBe('@Kenso');
+ expect(nameLine()?.getAttribute('title')).toBe('@Kenso');
+ });
+
+ 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']);
});
});
diff --git a/web/src/account/account-menu.ts b/web/src/account/account-menu.ts
index da542c9..7205161 100644
--- a/web/src/account/account-menu.ts
+++ b/web/src/account/account-menu.ts
@@ -1,5 +1,5 @@
-// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale
-// und einem Menü (E-Mail, „Meine Karten“, „Abmelden“). Die Texte stehen in der Sprache der Seite; nach einem Sprachwechsel
+// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale und einem Menü (Benutzername und E-Mail,
+// „Meine Karten“, „Benutzername ändern“, „Abmelden“). Die Texte stehen in der Sprache der Seite; nach einem Sprachwechsel
// baut main.ts das Menü neu auf.
import './account.css';
import { el } from '../editor/dom';
@@ -7,6 +7,8 @@ import { t } from '../i18n/i18n';
import { currentUser, logout, onAuthChange } from './auth';
import { errorMessage } from './errors';
import { requireLogin } from './login-dialog';
+import { myUsername, onUsernameChange } from './profile';
+import { usernameDialog } from './username-dialog';
export interface AccountMenu {
/** Zeigt das Menü erst, wenn der Anmeldestatus bekannt ist (kein Aufblitzen von „Anmelden“ bei angemeldeten Nutzern). */
@@ -46,11 +48,20 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
},
initial,
);
+ // Mit Benutzernamen steht „@name“ oben und die E-Mail klein darunter; ohne Namen (noch nicht gelesen oder nicht gewählt) nur die E-Mail.
+ const name = myUsername();
const email = el('span', { class: 'account-email', title: user.email }, user.email);
+ const head = el(
+ 'div',
+ { class: 'account-head' },
+ ...(name ? [el('span', { class: 'account-name', title: `@${name}` }, `@${name}`)] : []),
+ email,
+ );
const myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, t('Meine Karten'));
+ const changeName = el('button', { type: 'button', class: 'account-item' }, name ? t('Benutzername ändern') : t('Benutzername wählen'));
const signOut = el('button', { type: 'button', class: 'account-item' }, t('Abmelden'));
const error = el('span', { class: 'account-error', role: 'alert' });
- const dropdown = el('div', { class: 'account-dropdown', id: 'account-dropdown', hidden: true }, email, myCards, signOut, error);
+ const dropdown = el('div', { class: 'account-dropdown', id: 'account-dropdown', hidden: true }, head, myCards, changeName, signOut, error);
const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': t('Konto') }, avatar, dropdown);
let isOpen = false;
@@ -79,6 +90,14 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
document.removeEventListener('keydown', onKeydown);
};
+ changeName.addEventListener('click', () => {
+ setOpen(false);
+ // Der Eintrag im geschlossenen (und nach dem Speichern neu gezeichneten) Menü kann den Fokus nicht halten: er geht an den Avatar.
+ usernameDialog({ mode: 'change' })
+ .catch((err: unknown) => console.error('Der Dialog „Benutzername“ konnte nicht geöffnet werden', err))
+ .finally(() => slot.querySelector('.account-avatar')?.focus());
+ });
+
signOut.addEventListener('click', () => {
signOut.disabled = true;
error.textContent = '';
@@ -92,6 +111,8 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
}
const off = onAuthChange(render);
+ // Der Name steht fest oder ändert sich (gelesen nach dem Start, gespeichert im Dialog): das Menü zeigt gleich den neuen.
+ const offName = onUsernameChange(render);
render();
return {
@@ -101,6 +122,7 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
},
dispose() {
off();
+ offName();
detach?.();
detach = null;
slot.replaceChildren();
diff --git a/web/src/account/account.css b/web/src/account/account.css
index 92adb88..2d61123 100644
--- a/web/src/account/account.css
+++ b/web/src/account/account.css
@@ -127,18 +127,38 @@
display: none;
}
-.account-email {
+/* Kopf des Menüs: „@name“ (falls das Konto einen hat) und darunter die E-Mail */
+
+.account-head {
+ display: flex;
+ flex-direction: column;
min-width: 0;
margin-bottom: 4px;
padding: 6px 12px 10px;
- overflow: hidden;
border-bottom: 1px solid var(--line);
+}
+
+.account-name {
+ min-width: 0;
+ overflow: hidden;
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.account-email {
+ min-width: 0;
+ overflow: hidden;
color: var(--muted);
font-size: 0.9rem;
text-overflow: ellipsis;
white-space: nowrap;
}
+.account-name + .account-email {
+ font-size: 0.8rem; /* mit Namen steht die E-Mail klein darunter */
+}
+
.account-item {
appearance: none;
display: block;
@@ -346,6 +366,25 @@
text-align: center;
}
+/* Dialog „Benutzername wählen“: Hülle, Felder, Hinweise, Meldungen und Knöpfe wie der Login-Dialog */
+
+.username-dialog .auth-title {
+ margin-right: 0; /* kein Schließen-Knopf in der Ecke */
+}
+
+.username-text {
+ margin: 0;
+ color: var(--muted);
+ overflow-wrap: anywhere;
+}
+
+.username-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
/* Seite /passwort-neu */
.reset-page {
diff --git a/web/src/account/appwrite.ts b/web/src/account/appwrite.ts
index 6ba5f4b..ed453f5 100644
--- a/web/src/account/appwrite.ts
+++ b/web/src/account/appwrite.ts
@@ -8,7 +8,8 @@ export const APPWRITE_PROJECT: string = import.meta.env.VITE_APPWRITE_PROJECT_ID
/**
* Datenbank, Tabellen und Buckets des Projekts (siehe web/scripts/appwrite-setup.mjs).
* `table`/`bucket` sind die privaten Karten; `gallery`/`reports`/`galleryBucket` gehören zur Community-Galerie,
- * `logos`/`logoLikes`/`logoReports`/`logoBucket` zu den Community-Logos (Logos, Herzen, Meldungen, Logo-Dateien).
+ * `logos`/`logoLikes`/`logoReports`/`logoBucket` zu den Community-Logos (Logos, Herzen, Meldungen, Logo-Dateien),
+ * `profiles` zu den Benutzernamen (eine öffentlich lesbare Zeile je Konto; geschrieben wird nur über den API-Dienst).
*/
export const IDS = {
database: 'aircard',
@@ -21,6 +22,7 @@ export const IDS = {
logoLikes: 'logo_likes',
logoReports: 'logo_reports',
logoBucket: 'logo-images',
+ profiles: 'profiles',
} as const;
export const client: Client = new Client().setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT);
diff --git a/web/src/account/login-dialog.test.ts b/web/src/account/login-dialog.test.ts
index 0db2635..50c256d 100644
--- a/web/src/account/login-dialog.test.ts
+++ b/web/src/account/login-dialog.test.ts
@@ -9,9 +9,22 @@ const mocks = vi.hoisted(() => ({
updateRecovery: vi.fn(),
}));
+// Die Namensprüfung im Netz und der Namensdialog sind ersetzt; Muster, Sperrliste und der Merker „schon gefragt“ bleiben echt
+// (profile.ts wird nach jedem Zurücksetzen der Module mit `doMock` neu ersetzt, siehe beforeEach).
+const profileMocks = vi.hoisted(() => ({
+ isUsernameTaken: vi.fn(),
+ saveUsername: vi.fn(),
+ usernameDialog: vi.fn(),
+}));
+
vi.mock('./appwrite', () => ({ account: mocks }));
+vi.mock('./username-dialog', () => ({ usernameDialog: profileMocks.usernameDialog }));
const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type });
+const INVALID =
+ 'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.';
+const TAKEN = 'Dieser Benutzername ist schon vergeben.';
+const PROMPTED_KEY = 'aircard.usernamePrompted';
type Dialog = typeof import('./login-dialog');
let requireLogin: Dialog['requireLogin'];
@@ -19,7 +32,17 @@ let opener: HTMLButtonElement;
beforeEach(async () => {
for (const fn of Object.values(mocks)) fn.mockReset();
+ for (const fn of Object.values(profileMocks)) fn.mockReset();
+ profileMocks.isUsernameTaken.mockResolvedValue(false); // der Name ist frei
+ profileMocks.saveUsername.mockImplementation(async (name: string) => name);
+ profileMocks.usernameDialog.mockResolvedValue(null);
+ sessionStorage.clear();
vi.resetModules();
+ vi.doMock('./profile', async () => ({
+ ...(await vi.importActual('./profile')),
+ isUsernameTaken: profileMocks.isUsernameTaken,
+ saveUsername: profileMocks.saveUsername,
+ }));
document.body.innerHTML = '';
opener = document.getElementById('opener') as HTMLButtonElement;
opener.focus();
@@ -51,6 +74,7 @@ describe('requireLogin: Öffnen und Schließen', () => {
it('startet auf Wunsch mit Registrieren oder Passwort vergessen', () => {
void requireLogin('register');
expect(title()).toBe('Registrieren');
+ expect(input('username').autocomplete).toBe('username');
expect(input('password').autocomplete).toBe('new-password');
expect(input('password-repeat').autocomplete).toBe('new-password');
});
@@ -213,13 +237,14 @@ describe('Registrieren', () => {
it('prüft vor dem Senden: Abgleich, dann Länge', () => {
void requireLogin('register');
- fill({ email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim124' });
+ fill({ username: 'Kenso', email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim124' });
submit();
expect(alertText()).toBe('Die Passwörter stimmen nicht überein.');
fill({ password: 'kurz', 'password-repeat': 'kurz' });
submit();
expect(alertText()).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
expect(mocks.create).not.toHaveBeenCalled();
+ expect(profileMocks.isUsernameTaken).not.toHaveBeenCalled(); // erst die Eingaben, die ohne Netz zu prüfen sind
});
it('legt das Konto an, meldet an und löst mit true auf', async () => {
@@ -227,7 +252,7 @@ describe('Registrieren', () => {
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
const result = requireLogin('register');
- fill({ email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
+ fill({ username: 'Neu.Konto', email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
submit();
await expect(result).resolves.toBe(true);
expect(mocks.create).toHaveBeenCalledTimes(1);
@@ -237,7 +262,7 @@ describe('Registrieren', () => {
it('bei vorhandener E-Mail steht die Meldung mit einem Link „Anmelden“', async () => {
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
void requireLogin('register');
- fill({ email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
+ fill({ username: 'Kenso', email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
submit();
await vi.waitFor(() => expect(alertText()).toContain('Diese E-Mail ist schon registriert. Melde dich an.'));
const link = dialogEl().querySelector('[role="alert"] button')!;
@@ -248,6 +273,305 @@ describe('Registrieren', () => {
});
});
+describe('Registrieren: Benutzername', () => {
+ /** Meldet das Konto an wie ein erfolgreicher Server: anlegen, Sitzung, Nutzer. */
+ function serverAccepts(): void {
+ mocks.create.mockResolvedValue({});
+ mocks.createEmailPasswordSession.mockResolvedValue({});
+ mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
+ }
+ const fields = () => ({ username: 'Kenso', email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
+ const names = () => [...dialogEl().querySelectorAll('input')].map((i) => i.name);
+
+ it('das Pflichtfeld „Benutzername“ steht vor der E-Mail, mit Hinweis, und hat zuerst den Fokus', () => {
+ void requireLogin('register');
+ expect(names()).toEqual(['username', 'email', 'password', 'password-repeat']);
+ const field = input('username');
+ expect(field.type).toBe('text');
+ expect(field.required).toBe(true);
+ expect(field.autocomplete).toBe('username');
+ expect(field.getAttribute('autocapitalize')).toBe('none');
+ expect(field.getAttribute('spellcheck')).toBe('false');
+ expect(field.labels![0].textContent).toContain('Benutzername');
+ const hint = dialogEl().querySelector(`#${field.getAttribute('aria-describedby')}`)!;
+ expect(hint.textContent).toBe('3–20 Zeichen: Buchstaben, Zahlen, . _ -');
+ expect(document.activeElement).toBe(field);
+ });
+
+ it('die Anmelden-Ansicht hat kein Namensfeld', () => {
+ void requireLogin('login');
+ expect(names()).toEqual(['email', 'password']);
+ });
+
+ it.each([
+ ['leer', '', INVALID],
+ ['nur Leerzeichen', ' ', INVALID],
+ ['zu kurz', 'ab', INVALID],
+ ['zu lang', 'x'.repeat(21), INVALID],
+ ['falsches erstes Zeichen', '_abc', INVALID],
+ ['Leerzeichen im Namen', 'ke nso', INVALID],
+ ['Umlaut', 'Käse', INVALID],
+ ['gesperrt', 'Admin', 'Dieser Benutzername ist nicht erlaubt.'],
+ ['gesperrt in anderer Schreibweise', 'AirCard-IOS', 'Dieser Benutzername ist nicht erlaubt.'],
+ ])('%s: der Fehler steht am Feld, es gibt keine Anfrage und kein Konto', (_label, name, message) => {
+ void requireLogin('register');
+ fill({ ...fields(), username: name });
+ submit();
+ expect(alertText()).toBe(message);
+ const field = input('username');
+ expect(field.getAttribute('aria-invalid')).toBe('true');
+ expect(field.getAttribute('aria-describedby')!.split(' ')).toContain(dialogEl().querySelector('[role="alert"]')!.id);
+ expect(document.activeElement).toBe(field);
+ expect(profileMocks.isUsernameTaken).not.toHaveBeenCalled();
+ expect(mocks.create).not.toHaveBeenCalled();
+ expect(dialogEl().open).toBe(true);
+ });
+
+ it('der Name wird vor den Passwörtern geprüft (Reihenfolge der Felder)', () => {
+ void requireLogin('register');
+ fill({ ...fields(), username: 'x', 'password-repeat': 'anders1234' });
+ submit();
+ expect(alertText()).toBe(INVALID);
+ expect(document.activeElement).toBe(input('username'));
+ });
+
+ it('die Markierung des Feldes fällt beim nächsten Absenden weg; der Hinweis bleibt verknüpft', async () => {
+ serverAccepts();
+ void requireLogin('register');
+ fill({ ...fields(), username: 'x' });
+ submit();
+ expect(input('username').getAttribute('aria-describedby')!.split(' ')).toHaveLength(2);
+ fill({ username: 'Kenso' });
+ submit();
+ expect(input('username').hasAttribute('aria-invalid')).toBe(false);
+ expect(input('username').getAttribute('aria-describedby')!.split(' ')).toHaveLength(1);
+ await vi.waitFor(() => expect(mocks.create).toHaveBeenCalled());
+ });
+
+ it('vergebener Name (Review-Fokus): Fehler am Feld, bevor ein Konto angelegt wird – kein halbes Konto', async () => {
+ profileMocks.isUsernameTaken.mockResolvedValue(true);
+ void requireLogin('register');
+ fill({ ...fields(), username: ' kenso ' });
+ dialogEl().querySelector('button[type="submit"]')!.focus(); // der Nutzer hat den Knopf gedrückt
+ submit();
+ await vi.waitFor(() => expect(alertText()).toBe(TAKEN));
+ expect(profileMocks.isUsernameTaken).toHaveBeenCalledExactlyOnceWith('kenso'); // getrimmt, so wie er gespeichert würde
+ expect(mocks.create).not.toHaveBeenCalled();
+ expect(mocks.createEmailPasswordSession).not.toHaveBeenCalled();
+ expect(profileMocks.saveUsername).not.toHaveBeenCalled();
+ const field = input('username');
+ expect(field.getAttribute('aria-invalid')).toBe('true');
+ expect(field.getAttribute('aria-describedby')!.split(' ')).toContain(dialogEl().querySelector('[role="alert"]')!.id);
+ expect(document.activeElement).toBe(field); // der Fokus geht an das Feld, nicht zurück auf den Knopf
+ expect(dialogEl().open).toBe(true);
+ // das Formular ist wieder bedienbar
+ expect(dialogEl().querySelector('button[type="submit"]')!.disabled).toBe(false);
+ expect(field.readOnly).toBe(false);
+ expect(input('password').value).toBe('geheim123'); // nichts wurde verworfen
+ });
+
+ it('nach „vergeben“ geht ein anderer Name: dann wird das Konto angelegt', async () => {
+ serverAccepts();
+ profileMocks.isUsernameTaken.mockResolvedValueOnce(true);
+ const result = requireLogin('register');
+ fill(fields());
+ submit();
+ await vi.waitFor(() => expect(alertText()).toBe(TAKEN));
+ fill({ username: 'Kenso2' });
+ submit();
+ await expect(result).resolves.toBe(true);
+ expect(profileMocks.isUsernameTaken).toHaveBeenLastCalledWith('Kenso2');
+ expect(mocks.create).toHaveBeenCalledTimes(1);
+ expect(profileMocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso2');
+ });
+
+ it('lässt sich die Verfügbarkeit nicht prüfen (kein Netz), wird kein Konto angelegt und der Fehler erklärt', async () => {
+ profileMocks.isUsernameTaken.mockRejectedValue(new TypeError('Failed to fetch'));
+ let settled = false;
+ void requireLogin('register').then(() => (settled = true));
+ fill(fields());
+ submit();
+ await vi.waitFor(() => expect(alertText()).toBe('Keine Verbindung zum Server.'));
+ expect(mocks.create).not.toHaveBeenCalled();
+ expect(settled).toBe(false);
+ expect(dialogEl().open).toBe(true);
+ expect(dialogEl().querySelector('button[type="submit"]')!.disabled).toBe(false);
+ });
+
+ it('der Ablauf: Verfügbarkeit prüfen, Konto anlegen, anmelden, Namen speichern – erst dann ist der Dialog fertig', async () => {
+ serverAccepts();
+ const order: string[] = [];
+ profileMocks.isUsernameTaken.mockImplementation(async () => (order.push('prüfen'), false));
+ mocks.create.mockImplementation(async () => (order.push('anlegen'), {}));
+ mocks.createEmailPasswordSession.mockImplementation(async () => (order.push('anmelden'), {}));
+ profileMocks.saveUsername.mockImplementation(async (name: string) => (order.push(`speichern ${name}`), name));
+ const result = requireLogin('register');
+ fill({ ...fields(), username: ' Kenso ' });
+ submit();
+ await expect(result).resolves.toBe(true);
+ expect(order).toEqual(['prüfen', 'anlegen', 'anmelden', 'speichern Kenso']);
+ expect(mocks.create.mock.calls[0][0]).toMatchObject({ email: 'neu@b.de', password: 'geheim123' });
+ expect(dialogEl().open).toBe(false);
+ expect(profileMocks.usernameDialog).not.toHaveBeenCalled(); // Name gespeichert: nichts mehr zu fragen
+ });
+
+ it('während geprüft und angelegt wird, ist das Formular gesperrt und ein zweites Absenden wird ignoriert', async () => {
+ serverAccepts();
+ let finishCheck!: (taken: boolean) => void;
+ profileMocks.isUsernameTaken.mockReturnValue(new Promise((resolve) => (finishCheck = resolve)));
+ const result = requireLogin('register');
+ fill(fields());
+ submit();
+ submit();
+ expect(profileMocks.isUsernameTaken).toHaveBeenCalledTimes(1);
+ expect(dialogEl().querySelector('button[type="submit"]')!.disabled).toBe(true);
+ expect(input('username').readOnly).toBe(true);
+ finishCheck(false);
+ await expect(result).resolves.toBe(true);
+ expect(mocks.create).toHaveBeenCalledTimes(1);
+ });
+
+ it('scheitert das Speichern des Namens (Rennen, Netz), ist man trotzdem angemeldet: der Dialog schließt mit true, der Namensdialog erscheint', async () => {
+ serverAccepts();
+ profileMocks.saveUsername.mockRejectedValue(new Error('409'));
+ const result = requireLogin('register');
+ fill(fields());
+ submit();
+ await expect(result).resolves.toBe(true);
+ expect(dialogEl().open).toBe(false);
+ expect(mocks.get).toHaveBeenCalled(); // angemeldet
+ expect(profileMocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'prompt' });
+ });
+
+ it('der Namensdialog kommt erst, wenn der Login-Dialog zu ist, und ein Fehler beim Öffnen bleibt ohne Folgen', async () => {
+ serverAccepts();
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
+ profileMocks.saveUsername.mockRejectedValue(new Error('offline'));
+ let loginOpenWhenAsked: boolean | undefined;
+ profileMocks.usernameDialog.mockImplementation(async () => {
+ loginOpenWhenAsked = dialogEl().open;
+ throw new Error('showModal kaputt');
+ });
+ const result = requireLogin('register');
+ fill(fields());
+ submit();
+ await expect(result).resolves.toBe(true);
+ await vi.waitFor(() => expect(error).toHaveBeenCalled());
+ expect(loginOpenWhenAsked).toBe(false);
+ error.mockRestore();
+ });
+
+ it('das Konto-Anlegen scheitert (E-Mail vergeben): kein Namensdialog, kein Speichern', async () => {
+ mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
+ void requireLogin('register');
+ fill(fields());
+ submit();
+ await vi.waitFor(() => expect(alertText()).toContain('Diese E-Mail ist schon registriert.'));
+ expect(profileMocks.saveUsername).not.toHaveBeenCalled();
+ expect(profileMocks.usernameDialog).not.toHaveBeenCalled();
+ });
+
+ it('Merker „in dieser Sitzung schon gefragt“: gesetzt, bevor die Anmeldung läuft (sie löst die einmalige Abfrage aus), und danach noch da', async () => {
+ serverAccepts();
+ let flagWhenAccountCreated: string | null | undefined;
+ mocks.create.mockImplementation(async () => {
+ flagWhenAccountCreated = sessionStorage.getItem(PROMPTED_KEY);
+ return {};
+ });
+ const result = requireLogin('register');
+ fill(fields());
+ submit();
+ await expect(result).resolves.toBe(true);
+ expect(flagWhenAccountCreated).toBe('1');
+ expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1');
+ });
+
+ it('scheitert das Anlegen des Kontos, nimmt der Dialog den Merker wieder weg (wer sich danach anmeldet, wird noch gefragt)', async () => {
+ mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
+ void requireLogin('register');
+ fill(fields());
+ submit();
+ await vi.waitFor(() => expect(alertText()).toContain('schon registriert'));
+ expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
+ });
+
+ it('war schon gefragt worden, bleibt der Merker auch nach einem gescheiterten Anlegen', async () => {
+ sessionStorage.setItem(PROMPTED_KEY, '1');
+ mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
+ void requireLogin('register');
+ fill(fields());
+ submit();
+ await vi.waitFor(() => expect(alertText()).toContain('schon registriert'));
+ expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1');
+ });
+
+ it('wird der Dialog während der Prüfung geschlossen, entsteht kein Konto', async () => {
+ serverAccepts();
+ let finishCheck!: (taken: boolean) => void;
+ profileMocks.isUsernameTaken.mockReturnValue(new Promise((resolve) => (finishCheck = resolve)));
+ const result = requireLogin('register');
+ fill(fields());
+ submit();
+ dialogEl().querySelector('.auth-close')!.click();
+ await expect(result).resolves.toBe(false);
+ finishCheck(false);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(mocks.create).not.toHaveBeenCalled();
+ expect(profileMocks.saveUsername).not.toHaveBeenCalled();
+ expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
+ });
+
+ it('wird der Dialog während der Prüfung geschlossen und der Name ist vergeben, erscheint nichts mehr', async () => {
+ let finishCheck!: (taken: boolean) => void;
+ profileMocks.isUsernameTaken.mockReturnValue(new Promise((resolve) => (finishCheck = resolve)));
+ const result = requireLogin('register');
+ fill(fields());
+ submit();
+ dialogEl().querySelector('.auth-close')!.click();
+ await expect(result).resolves.toBe(false);
+ finishCheck(true);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(dialogEl().querySelector('[role="alert"]')?.textContent ?? '').toBe('');
+ expect(mocks.create).not.toHaveBeenCalled();
+ });
+
+ it('der getippte Name wandert beim Wechsel der Reiter mit (wie die E-Mail), Passwörter nie', () => {
+ void requireLogin('register');
+ fill({ username: 'Kenso', email: 'a@b.de', password: 'geheim123' });
+ buttonByText('Anmelden').click();
+ expect(title()).toBe('Anmelden');
+ expect(input('email').value).toBe('a@b.de');
+ buttonByText('Registrieren').click();
+ expect(input('username').value).toBe('Kenso');
+ expect(input('email').value).toBe('a@b.de');
+ expect(input('password').value).toBe('');
+ });
+
+ it('nach dem Schließen sind Name und E-Mail weg, ein neuer Aufruf beginnt leer', async () => {
+ const first = requireLogin('register');
+ fill({ username: 'Kenso', email: 'a@b.de' });
+ dialogEl().querySelector('.auth-close')!.click();
+ await first;
+ void requireLogin('register');
+ expect(input('username').value).toBe('');
+ expect(input('email').value).toBe('');
+ expect(document.activeElement).toBe(input('username'));
+ });
+
+ it('die anderen Ansichten bleiben unberührt: Anmelden meldet an, ohne Name zu prüfen oder zu speichern', async () => {
+ mocks.createEmailPasswordSession.mockResolvedValue({});
+ mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
+ const result = requireLogin();
+ fill({ email: 'a@b.de', password: 'geheim123' });
+ submit();
+ await expect(result).resolves.toBe(true);
+ expect(profileMocks.isUsernameTaken).not.toHaveBeenCalled();
+ expect(profileMocks.saveUsername).not.toHaveBeenCalled();
+ expect(profileMocks.usernameDialog).not.toHaveBeenCalled();
+ expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull(); // die einmalige Abfrage ist Sache von main.ts
+ });
+});
+
describe('Passwort vergessen', () => {
it('führt vom Anmelden zur E-Mail-Eingabe und sendet den Link', async () => {
mocks.createRecovery.mockResolvedValue({});
diff --git a/web/src/account/login-dialog.ts b/web/src/account/login-dialog.ts
index 8c58a18..cdaf2b9 100644
--- a/web/src/account/login-dialog.ts
+++ b/web/src/account/login-dialog.ts
@@ -1,9 +1,20 @@
-// Login-Dialog: ein gemeinsames