From 3e4bc21f00aff752c7eb675ed5bd3f35202ced5c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:22:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(account):=20Benutzername=20beim=20Registri?= =?UTF-8?q?eren,=20im=20Konto-Men=C3=BC=20und=20als=20einmalige=20Abfrage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/src/account/account-menu.test.ts | 169 ++++++- web/src/account/account-menu.ts | 28 +- web/src/account/account.css | 43 +- web/src/account/appwrite.ts | 4 +- web/src/account/login-dialog.test.ts | 330 +++++++++++- web/src/account/login-dialog.ts | 115 ++++- web/src/account/profile.test.ts | 554 +++++++++++++++++++++ web/src/account/profile.ts | 256 ++++++++++ web/src/account/username-dialog.test.ts | 633 ++++++++++++++++++++++++ web/src/account/username-dialog.ts | 243 +++++++++ web/src/i18n/en.ts | 11 + web/src/i18n/english-ui.test.ts | 41 +- web/src/main.test.ts | 238 ++++++++- web/src/main.ts | 35 +- 14 files changed, 2660 insertions(+), 40 deletions(-) create mode 100644 web/src/account/profile.test.ts create mode 100644 web/src/account/profile.ts create mode 100644 web/src/account/username-dialog.test.ts create mode 100644 web/src/account/username-dialog.ts 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 mit Anmelden, Registrieren und „Passwort vergessen“. +// Login-Dialog: ein gemeinsames 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 = { 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 { 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 { 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 { 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 { 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, + action: () => Promise, onError?: (err: unknown) => Node | undefined, ): Promise { 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 { 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 { + 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 { } 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 { 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 { /** 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('input[name="email"]'); if (typed) draftEmail = typed.value.trim(); + const typedName = content.querySelector('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 { if (pending) return pending; opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; draftEmail = ''; + draftUsername = ''; busy = false; pending = new Promise((resolve) => { settle = resolve; diff --git a/web/src/account/profile.test.ts b/web/src/account/profile.test.ts new file mode 100644 index 0000000..e99a57e --- /dev/null +++ b/web/src/account/profile.test.ts @@ -0,0 +1,554 @@ +import { createRequire } from 'node:module'; +import { Query } from 'appwrite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + listRows: vi.fn(), + getRow: vi.fn(), + apiPost: vi.fn(), + userId: null as string | null, +})); + +vi.mock('./appwrite', () => ({ + IDS: { database: 'db', profiles: 'profiles-table' }, + tables: { listRows: mocks.listRows, getRow: mocks.getRow }, + account: {}, + client: {}, + storage: {}, + APPWRITE_ENDPOINT: '', +})); +vi.mock('./auth', () => ({ currentUserId: () => mocks.userId, onAuthChange: () => () => {}, onLogout: () => () => {} })); +// Nur der Netzaufruf ist ersetzt; ApiError und unavailableError bleiben echt. +vi.mock('../community/api', async (importOriginal) => ({ + ...(await importOriginal()), + apiPost: mocks.apiPost, +})); + +// Muster, Sperrliste und Schlüssel liegen im Dienst (deploy/api/common.js, CommonJS ohne Abhängigkeiten); der Test lädt sie von dort. +const server = createRequire(import.meta.url)('../../../deploy/api/common.js') as { + USERNAME_PATTERN: RegExp; + RESERVED_USERNAMES: readonly string[]; + usernameKey: (name: string) => string; + isReservedUsername: (name: string) => boolean; +}; + +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 RESERVED = 'Dieser Benutzername ist nicht erlaubt.'; + +const notFound = (type = 'row_not_found') => Object.assign(new Error('Row not found'), { code: 404, type }); + +type Profile = typeof import('./profile'); +let profile: Profile; +/** Das Modul hält den Zwischenspeicher der Namen: jeder Test bekommt eine frische Instanz (samt i18n und ApiError). */ +let ApiError: typeof import('../community/api').ApiError; + +beforeEach(async () => { + for (const fn of [mocks.listRows, mocks.getRow, mocks.apiPost]) fn.mockReset(); + mocks.userId = null; + sessionStorage.clear(); + vi.resetModules(); + profile = await import('./profile'); + ({ ApiError } = await import('../community/api')); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('Regeln im Gleichlauf mit dem API-Dienst (deploy/api/common.js)', () => { + it('der Dienst liefert seine Regeln (sonst bestünde der Vergleich leer)', () => { + expect(server.RESERVED_USERNAMES.length).toBeGreaterThan(20); + expect(server.USERNAME_PATTERN.test('Kenso')).toBe(true); + }); + + it('das Muster ist dasselbe: genau ^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$', () => { + expect(profile.USERNAME_PATTERN.source).toBe('^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$'); + expect(profile.USERNAME_PATTERN.source).toBe(server.USERNAME_PATTERN.source); + expect(profile.USERNAME_PATTERN.flags).toBe(server.USERNAME_PATTERN.flags); + }); + + it('die Sperrliste ist dieselbe (Einträge und Reihenfolge)', () => { + expect([...profile.RESERVED_USERNAMES]).toEqual([...server.RESERVED_USERNAMES]); + }); + + it('jeder gesperrte Schlüssel passt zum Muster (sonst wäre er wirkungslos: der Name scheiterte schon als ungültig)', () => { + const unusable = profile.RESERVED_USERNAMES.filter((key) => !profile.USERNAME_PATTERN.test(key)); + expect(unusable).toEqual([]); + }); + + it('Schlüssel und Sperre urteilen wie der Dienst', () => { + for (const name of ['Kenso', 'KENSO', 'Admin', 'AirCard-IOS', 'aircard.ios', 'Mod', 'modx', 'a.b_c-d', 'Hilfe']) { + expect(profile.usernameKey(name)).toBe(server.usernameKey(name)); + expect(profile.validateUsername(name) === RESERVED).toBe(server.isReservedUsername(name)); + } + }); +}); + +describe('usernameKey', () => { + it('der Name in Kleinbuchstaben', () => { + expect(profile.usernameKey('Kenso')).toBe('kenso'); + expect(profile.usernameKey('AirCard-IOS')).toBe('aircard-ios'); + expect(profile.usernameKey('a.B_c-9')).toBe('a.b_c-9'); + }); +}); + +describe('validateUsername', () => { + it.each(['abc', 'a1b', 'Kenso', 'k.e_n-so', '0abc', '9..', 'x'.repeat(20), 'ABC', 'admin1', 'administrators', 'momo'])('gültig: %s', (name) => { + expect(profile.validateUsername(name)).toBeNull(); + }); + + it('Grenzen: 3 Zeichen sind das Minimum, 20 das Maximum', () => { + expect(profile.validateUsername('ab')).toBe(INVALID); + expect(profile.validateUsername('abc')).toBeNull(); + expect(profile.validateUsername('x'.repeat(20))).toBeNull(); + expect(profile.validateUsername('x'.repeat(21))).toBe(INVALID); + }); + + it('das erste Zeichen ist ein Buchstabe oder eine Zahl, kein Satzzeichen', () => { + for (const name of ['_abc', '.abc', '-abc']) expect(profile.validateUsername(name)).toBe(INVALID); + for (const name of ['a_c', 'a.c', 'a-c', '1bc']) expect(profile.validateUsername(name)).toBeNull(); + }); + + it.each(['', ' ', 'ab c', ' abc', 'abc ', 'abc\n', 'ab\tc', 'ab@c', 'ab/c', 'Käse', 'ünder', 'naïve', 'na😀me', 'ab:c', 'abc'])( + 'ungültig (Muster): %j', + (name) => { + expect(profile.validateUsername(name)).toBe(INVALID); + }, + ); + + it('gesperrt, in jeder Schreibweise', () => { + for (const key of profile.RESERVED_USERNAMES) { + expect(profile.validateUsername(key), key).toBe(RESERVED); + expect(profile.validateUsername(key.toUpperCase()), key).toBe(RESERVED); + } + expect(profile.validateUsername('Admin')).toBe(RESERVED); + expect(profile.validateUsername('AirCard-IOS')).toBe(RESERVED); + expect(profile.validateUsername('aIrCaRd.IoS')).toBe(RESERVED); + }); + + it('das Muster geht vor der Sperrliste', () => { + expect(profile.validateUsername('_admin')).toBe(INVALID); + }); + + it('der Text folgt der Sprache der Seite', async () => { + (await import('../i18n/i18n')).setLang('en', { persist: false }); + expect(profile.validateUsername('a')).toBe( + 'The username must be 3–20 characters long, start with a letter or a number, and may only contain letters, numbers, periods, underscores and hyphens.', + ); + expect(profile.validateUsername('Admin')).toBe('This username is not allowed.'); + }); +}); + +describe('IDS', () => { + it('kennt die Tabelle profiles (Name wie im Setup-Skript)', async () => { + vi.stubEnv('VITE_APPWRITE_ENDPOINT', 'https://aircard.test/v1'); + try { + const { IDS } = await vi.importActual('./appwrite'); + expect(IDS.profiles).toBe('profiles'); + expect(IDS.database).toBe('aircard'); // und das Bestehende bleibt + expect(IDS.logos).toBe('logos'); + } finally { + vi.unstubAllEnvs(); + } + }); +}); + +describe('getUsername', () => { + it('liest die Zeile mit der Konto-ID als Zeilen-ID', async () => { + mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Kenso', usernameKey: 'kenso' }); + await expect(profile.getUsername('u1')).resolves.toBe('Kenso'); + expect(mocks.getRow).toHaveBeenCalledExactlyOnceWith({ databaseId: 'db', tableId: 'profiles-table', rowId: 'u1' }); + }); + + it('kein Profil (404) ergibt null', async () => { + mocks.getRow.mockRejectedValue(notFound()); + await expect(profile.getUsername('u1')).resolves.toBeNull(); + }); + + it('eine Zeile ohne lesbaren Namen ergibt null', async () => { + mocks.getRow.mockResolvedValue({ $id: 'u1' }); + await expect(profile.getUsername('u1')).resolves.toBeNull(); + mocks.getRow.mockResolvedValue({ $id: 'u1', username: 42 }); + await expect(profile.getUsername('u1')).resolves.toBeNull(); + }); + + it('eine ID, die keine Appwrite-ID sein kann, ergibt null ohne Anfrage', async () => { + for (const id of ['', '../x', 'a b', 'x'.repeat(40)]) await expect(profile.getUsername(id)).resolves.toBeNull(); + expect(mocks.getRow).not.toHaveBeenCalled(); + }); + + it('andere Fehler (Netz, 500, fehlende Tabelle) gehen durch', async () => { + const offline = new TypeError('Failed to fetch'); + mocks.getRow.mockRejectedValueOnce(offline); + await expect(profile.getUsername('u1')).rejects.toBe(offline); + const server500 = Object.assign(new Error('boom'), { code: 500, type: 'general_server_error' }); + mocks.getRow.mockRejectedValueOnce(server500); + await expect(profile.getUsername('u1')).rejects.toBe(server500); + const noTable = notFound('table_not_found'); + mocks.getRow.mockRejectedValueOnce(noTable); + await expect(profile.getUsername('u1')).rejects.toBe(noTable); + }); +}); + +describe('usernamesFor', () => { + const row = (id: string, username: string) => ({ $id: id, username, usernameKey: username.toLowerCase() }); + + it('sammelt die IDs in einer einzigen Abfrage (equal auf $id)', async () => { + mocks.listRows.mockResolvedValue({ rows: [row('u1', 'Kenso'), row('u2', 'Mia')] }); + const names = await profile.usernamesFor(['u1', 'u2']); + expect([...names]).toEqual([ + ['u1', 'Kenso'], + ['u2', 'Mia'], + ]); + expect(mocks.listRows).toHaveBeenCalledExactlyOnceWith({ + databaseId: 'db', + tableId: 'profiles-table', + queries: [Query.equal('$id', ['u1', 'u2']), Query.limit(2)], + }); + }); + + it('doppelte IDs zählen einmal, Unbrauchbares wird gar nicht erst gefragt', async () => { + mocks.listRows.mockResolvedValue({ rows: [row('u1', 'Kenso')] }); + await profile.usernamesFor(['u1', 'u1', '', '../x', 'u1']); + expect(mocks.listRows.mock.calls[0][0].queries).toEqual([Query.equal('$id', ['u1']), Query.limit(1)]); + }); + + it('nichts zu fragen: leere Map ohne Anfrage', async () => { + expect((await profile.usernamesFor([])).size).toBe(0); + expect((await profile.usernamesFor(['', 'a b'])).size).toBe(0); + expect(mocks.listRows).not.toHaveBeenCalled(); + }); + + it('merkt sich die Namen: dieselben IDs fragen nicht noch einmal, neue IDs allein', async () => { + mocks.listRows.mockResolvedValueOnce({ rows: [row('u1', 'Kenso'), row('u2', 'Mia')] }); + await profile.usernamesFor(['u1', 'u2']); + + const again = await profile.usernamesFor(['u2', 'u1']); + expect([...again].sort()).toEqual([ + ['u1', 'Kenso'], + ['u2', 'Mia'], + ]); + expect(mocks.listRows).toHaveBeenCalledTimes(1); + + mocks.listRows.mockResolvedValueOnce({ rows: [row('u3', 'Lea')] }); + const more = await profile.usernamesFor(['u1', 'u3']); + expect(mocks.listRows).toHaveBeenCalledTimes(2); + expect(mocks.listRows.mock.calls[1][0].queries).toEqual([Query.equal('$id', ['u3']), Query.limit(1)]); + expect([...more].sort()).toEqual([ + ['u1', 'Kenso'], + ['u3', 'Lea'], + ]); + }); + + it('Konten ohne Profil fehlen in der Map und werden beim nächsten Mal wieder gefragt (sie können sich später einen Namen wählen)', async () => { + mocks.listRows.mockResolvedValue({ rows: [row('u1', 'Kenso')] }); + const names = await profile.usernamesFor(['u1', 'u9']); + expect(names.has('u9')).toBe(false); + await profile.usernamesFor(['u9']); + expect(mocks.listRows).toHaveBeenCalledTimes(2); + }); + + it('Zeilen ohne lesbaren Namen zählen nicht', async () => { + mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u1' }, { $id: 'u2', username: '' }, { $id: 'u3', username: 7 }, row('u4', 'Mia')] }); + const names = await profile.usernamesFor(['u1', 'u2', 'u3', 'u4']); + expect([...names]).toEqual([['u4', 'Mia']]); + }); + + it('Fehler ergeben eine leere Map statt eines Fehlers und merken nichts', async () => { + mocks.listRows.mockRejectedValueOnce(new TypeError('Failed to fetch')); + const names = await profile.usernamesFor(['u1', 'u2']); + expect(names).toBeInstanceOf(Map); + expect(names.size).toBe(0); + + mocks.listRows.mockResolvedValueOnce({ rows: [row('u1', 'Kenso')] }); + expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Kenso']]); // der nächste Versuch fragt wieder + }); + + it('bei einem Fehler bleiben die schon bekannten Namen erhalten', async () => { + mocks.listRows.mockResolvedValueOnce({ rows: [row('u1', 'Kenso')] }); + await profile.usernamesFor(['u1']); + mocks.listRows.mockRejectedValueOnce(Object.assign(new Error('boom'), { code: 500 })); + const names = await profile.usernamesFor(['u1', 'u2']); + expect([...names]).toEqual([['u1', 'Kenso']]); + }); + + it('viele IDs gehen in Gruppen zu je 100 (eine Gruppe pro Abfrage)', async () => { + const ids = Array.from({ length: 250 }, (_, i) => `u${i}`); + mocks.listRows.mockImplementation(async ({ queries }: { queries: string[] }) => { + const asked = (JSON.parse(queries[0]) as { values: string[] }).values; + return { rows: asked.map((id) => row(id, `Name${id}`)) }; + }); + const names = await profile.usernamesFor(ids); + expect(names.size).toBe(250); + expect(mocks.listRows.mock.calls.map((call) => (JSON.parse(call[0].queries[0]) as { values: string[] }).values.length)).toEqual([100, 100, 50]); + expect(mocks.listRows.mock.calls.map((call) => call[0].queries[1])).toEqual([Query.limit(100), Query.limit(100), Query.limit(50)]); + }); + + it('ein Name, der während der Abfrage durch Umbenennen im Speicher landet, ist neuer als die Antwort', async () => { + mocks.userId = 'u1'; + let answer!: (value: unknown) => void; + mocks.listRows.mockReturnValue(new Promise((resolve) => (answer = resolve))); + const pending = profile.usernamesFor(['u1']); + mocks.apiPost.mockResolvedValue({ username: 'Neu' }); + await profile.saveUsername('Neu'); + answer({ rows: [row('u1', 'Alt')] }); + expect([...(await pending)]).toEqual([['u1', 'Neu']]); + expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Neu']]); + }); +}); + +describe('isUsernameTaken', () => { + it('fragt den Schlüssel (klein geschrieben) mit equal ab, eine Zeile genügt', async () => { + mocks.listRows.mockResolvedValue({ rows: [] }); + await profile.isUsernameTaken('KenSo'); + expect(mocks.listRows).toHaveBeenCalledExactlyOnceWith({ + databaseId: 'db', + tableId: 'profiles-table', + queries: [Query.equal('usernameKey', 'kenso'), Query.limit(1)], + }); + }); + + it('vergeben, wenn ein Profil den Schlüssel hat', async () => { + mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u2', username: 'kenso', usernameKey: 'kenso' }] }); + await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(true); + }); + + it('frei, wenn keine Zeile passt', async () => { + mocks.listRows.mockResolvedValue({ rows: [] }); + await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(false); + }); + + it('der eigene Eintrag zählt nicht (nur die Schreibweise des eigenen Namens ändern ist erlaubt)', async () => { + mocks.userId = 'u1'; + mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u1', username: 'kenso', usernameKey: 'kenso' }] }); + await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(false); + mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u2', username: 'kenso', usernameKey: 'kenso' }] }); + await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(true); + }); + + it('Fehler gehen durch (die Registrierung zeigt sie am Dialog)', async () => { + const offline = new TypeError('Failed to fetch'); + mocks.listRows.mockRejectedValue(offline); + await expect(profile.isUsernameTaken('Kenso')).rejects.toBe(offline); + }); +}); + +describe('saveUsername', () => { + beforeEach(() => { + mocks.userId = 'u1'; + }); + + it('sendet POST /api/profil mit Anmeldung und gibt den Namen der Antwort zurück', async () => { + mocks.apiPost.mockResolvedValue({ username: 'Kenso' }); + await expect(profile.saveUsername('Kenso')).resolves.toBe('Kenso'); + expect(mocks.apiPost).toHaveBeenCalledExactlyOnceWith('/api/profil', { username: 'Kenso' }, { auth: true }); + }); + + it('aktualisiert den Zwischenspeicher: myUsername und usernamesFor kennen den Namen sofort, ohne Abfrage', async () => { + expect(profile.myUsername()).toBeNull(); + mocks.apiPost.mockResolvedValue({ username: 'Kenso' }); + await profile.saveUsername('Kenso'); + expect(profile.myUsername()).toBe('Kenso'); + expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Kenso']]); + expect(mocks.listRows).not.toHaveBeenCalled(); + }); + + it('meldet die Änderung mit Konto-ID und Name an alle Hörer; abgemeldete Hörer hören nichts mehr', async () => { + const first = vi.fn(); + const second = vi.fn(); + const off = profile.onUsernameChange(first); + profile.onUsernameChange(second); + mocks.apiPost.mockResolvedValueOnce({ username: 'Kenso' }); + await profile.saveUsername('Kenso'); + expect(first).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso'); + expect(second).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso'); + + off(); + mocks.apiPost.mockResolvedValueOnce({ username: 'Neu' }); + await profile.saveUsername('Neu'); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenLastCalledWith('u1', 'Neu'); + expect(profile.myUsername()).toBe('Neu'); + }); + + it('die Schreibweise der Antwort gilt (der Dienst speichert den Namen, wie er eingegeben wurde)', async () => { + mocks.apiPost.mockResolvedValue({ username: 'KenSo' }); + await expect(profile.saveUsername('kenso')).resolves.toBe('KenSo'); + expect(profile.myUsername()).toBe('KenSo'); + }); + + it('eine Antwort ohne Namen ist ein unklarer Ausgang: Fehler 503, nichts gemerkt, nichts gemeldet', async () => { + const listener = vi.fn(); + profile.onUsernameChange(listener); + for (const body of [{}, { username: '' }, { username: 5 }]) { + mocks.apiPost.mockResolvedValueOnce(body); + const err = await profile.saveUsername('Kenso').catch((e: unknown) => e); + expect(err).toBeInstanceOf(ApiError); + expect((err as InstanceType).serverCode).toBe('unavailable'); + } + expect(profile.myUsername()).toBeNull(); + expect(listener).not.toHaveBeenCalled(); + }); + + it('Fehler des Dienstes gehen unverändert durch, der Zwischenspeicher bleibt', async () => { + mocks.apiPost.mockResolvedValueOnce({ username: 'Kenso' }); + await profile.saveUsername('Kenso'); + const listener = vi.fn(); + profile.onUsernameChange(listener); + const taken = new ApiError(409, 'Dieser Benutzername ist schon vergeben.', undefined, 'usernameTaken'); + mocks.apiPost.mockRejectedValueOnce(taken); + await expect(profile.saveUsername('Mia')).rejects.toBe(taken); + expect(profile.myUsername()).toBe('Kenso'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('ein Hörer, der wirft, stört weder die anderen noch das Ergebnis', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const later = vi.fn(); + profile.onUsernameChange(() => { + throw new Error('kaputt'); + }); + profile.onUsernameChange(later); + mocks.apiPost.mockResolvedValue({ username: 'Kenso' }); + await expect(profile.saveUsername('Kenso')).resolves.toBe('Kenso'); + expect(later).toHaveBeenCalledOnce(); + expect(error).toHaveBeenCalledOnce(); + }); + + it('ohne bekanntes Konto geht der Aufruf durch, gemerkt und gemeldet wird nichts', async () => { + mocks.userId = null; + const listener = vi.fn(); + profile.onUsernameChange(listener); + mocks.apiPost.mockResolvedValue({ username: 'Kenso' }); + await expect(profile.saveUsername('Kenso')).resolves.toBe('Kenso'); + expect(listener).not.toHaveBeenCalled(); + expect(profile.myUsername()).toBeNull(); + }); + + it('gemerkt wird für das Konto, das den Aufruf gestartet hat, auch wenn inzwischen ein anderes angemeldet ist', async () => { + let answer!: (value: unknown) => void; + mocks.apiPost.mockReturnValue(new Promise((resolve) => (answer = resolve))); + const saving = profile.saveUsername('Kenso'); + mocks.userId = 'u2'; // Kontowechsel, während die Antwort unterwegs ist + answer({ username: 'Kenso' }); + await saving; + expect(profile.myUsername()).toBeNull(); // u2 hat (noch) keinen Namen + mocks.userId = 'u1'; + expect(profile.myUsername()).toBe('Kenso'); + }); +}); + +describe('myUsername', () => { + it('null ohne Konto und null, solange der Name nicht bekannt ist', () => { + expect(profile.myUsername()).toBeNull(); + mocks.userId = 'u1'; + expect(profile.myUsername()).toBeNull(); + }); + + it('der Name gehört zum angemeldeten Konto: bei einem Wechsel zeigt myUsername den des anderen (oder keinen)', async () => { + mocks.userId = 'u1'; + mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Kenso' }); + await profile.loadMyUsername(); + expect(profile.myUsername()).toBe('Kenso'); + mocks.userId = 'u2'; + expect(profile.myUsername()).toBeNull(); + mocks.userId = null; + expect(profile.myUsername()).toBeNull(); + mocks.userId = 'u1'; + expect(profile.myUsername()).toBe('Kenso'); + }); +}); + +describe('loadMyUsername', () => { + it('ohne Konto null und keine Anfrage', async () => { + await expect(profile.loadMyUsername()).resolves.toBeNull(); + expect(mocks.getRow).not.toHaveBeenCalled(); + }); + + it('liest das Profil des angemeldeten Kontos, merkt den Namen und meldet ihn', async () => { + mocks.userId = 'u1'; + mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Kenso' }); + const listener = vi.fn(); + profile.onUsernameChange(listener); + await expect(profile.loadMyUsername()).resolves.toBe('Kenso'); + expect(mocks.getRow).toHaveBeenCalledExactlyOnceWith({ databaseId: 'db', tableId: 'profiles-table', rowId: 'u1' }); + expect(profile.myUsername()).toBe('Kenso'); + expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso'); + }); + + it('liest bei jedem Aufruf neu (der Name kann auf einem anderen Gerät geändert worden sein)', async () => { + mocks.userId = 'u1'; + mocks.getRow.mockResolvedValueOnce({ $id: 'u1', username: 'Alt' }).mockResolvedValueOnce({ $id: 'u1', username: 'Neu' }); + await profile.loadMyUsername(); + await expect(profile.loadMyUsername()).resolves.toBe('Neu'); + expect(profile.myUsername()).toBe('Neu'); + }); + + it('ohne Profil (404) ist das Ergebnis null, nichts wird gemeldet', async () => { + mocks.userId = 'u1'; + mocks.getRow.mockRejectedValue(notFound()); + const listener = vi.fn(); + profile.onUsernameChange(listener); + await expect(profile.loadMyUsername()).resolves.toBeNull(); + expect(profile.myUsername()).toBeNull(); + expect(listener).not.toHaveBeenCalled(); + }); + + it('ein Name, der inzwischen gespeichert wurde, bleibt, auch wenn die Antwort „kein Profil“ sagt (Registrierung)', async () => { + mocks.userId = 'u1'; + let answer!: (error: unknown) => void; + mocks.getRow.mockReturnValue(new Promise((_, reject) => (answer = reject))); + const loading = profile.loadMyUsername(); + mocks.apiPost.mockResolvedValue({ username: 'Kenso' }); + await profile.saveUsername('Kenso'); // das Konto wurde gerade angelegt: die Abfrage war schneller als das Speichern + answer(notFound()); + await expect(loading).resolves.toBe('Kenso'); + expect(profile.myUsername()).toBe('Kenso'); + }); + + it('andere Fehler gehen durch (der Aufrufer entscheidet; „unbekannt“ ist nicht „kein Name“)', async () => { + mocks.userId = 'u1'; + const offline = new TypeError('Failed to fetch'); + mocks.getRow.mockRejectedValue(offline); + await expect(profile.loadMyUsername()).rejects.toBe(offline); + }); +}); + +describe('Merker „in dieser Sitzung schon gefragt“', () => { + it('beginnt leer; markieren schreibt aircard.usernamePrompted in sessionStorage', () => { + expect(profile.usernamePrompted()).toBe(false); + profile.markUsernamePrompted(); + expect(profile.usernamePrompted()).toBe(true); + expect(sessionStorage.getItem('aircard.usernamePrompted')).toBe('1'); + }); + + it('gilt für die ganze Sitzung: auch eine frisch geladene Seite sieht ihn', async () => { + profile.markUsernamePrompted(); + vi.resetModules(); + const reloaded = await import('./profile'); + expect(reloaded.usernamePrompted()).toBe(true); + }); + + it('löschen nimmt ihn wieder weg', () => { + profile.markUsernamePrompted(); + profile.clearUsernamePrompted(); + expect(profile.usernamePrompted()).toBe(false); + expect(sessionStorage.getItem('aircard.usernamePrompted')).toBeNull(); + }); + + it('ist der Speicher gesperrt, wirft nichts und der Merker gilt bis zum Neuladen der Seite', () => { + const denied = () => { + throw new DOMException('blockiert', 'SecurityError'); + }; + vi.stubGlobal('sessionStorage', { getItem: denied, setItem: denied, removeItem: denied }); + try { + expect(profile.usernamePrompted()).toBe(false); + expect(() => profile.markUsernamePrompted()).not.toThrow(); + expect(profile.usernamePrompted()).toBe(true); + expect(() => profile.clearUsernamePrompted()).not.toThrow(); + expect(profile.usernamePrompted()).toBe(false); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/web/src/account/profile.ts b/web/src/account/profile.ts new file mode 100644 index 0000000..652d062 --- /dev/null +++ b/web/src/account/profile.ts @@ -0,0 +1,256 @@ +// Benutzernamen (Spec 2026-10-05-aircard-benutzernamen-design.md): die Regeln für den Namen, Profile lesen (Tabelle `profiles`: eine +// öffentlich lesbare Zeile je Konto, Zeilen-ID = Konto-ID) und den eigenen Namen speichern (`POST /api/profil` des Dienstes +// `aircard-api`). Der Browser prüft nur vor; was gilt, entscheidet der Dienst: Er prüft Muster und Sperrliste noch einmal, und der +// Unique-Index auf `usernameKey` hält einen Namen auch bei zwei gleichzeitigen Anfragen nur einmal. +import { Query, type Models } from 'appwrite'; +import { ID_PATTERN } from '../card/config'; +import { apiPost, unavailableError } from '../community/api'; +import { API_MESSAGES } from '../i18n/api-messages'; +import { msg, t } from '../i18n/i18n'; +import { IDS, tables } from './appwrite'; +import { currentUserId } from './auth'; + +/** Wie im Dienst (deploy/api/common.js): 3–20 Zeichen, das erste ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, `.`, `_`, `-`. */ +export const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$/; + +/** + * Gesperrte Namen als Schlüssel (klein geschrieben, Spec § 2): Rollen, der Dienst selbst und Platzhalter, die nach einem Konto aussehen. + * Genau die Liste des Dienstes (deploy/api/common.js, RESERVED_USERNAMES); profile.test.ts vergleicht beide. + */ +export const RESERVED_USERNAMES: readonly string[] = [ + 'admin', + 'administrator', + 'root', + 'system', + 'support', + 'moderator', + 'mod', + 'aircard', + 'aircardios', + 'aircard-ios', + 'aircard.ios', + 'aircard_ios', + 'webklar', + 'anonym', + 'anonymous', + 'null', + 'undefined', + 'team', + 'staff', + 'official', + 'offiziell', + 'hilfe', + 'help', + 'kontakt', + 'contact', + 'info', +]; +const RESERVED = new Set(RESERVED_USERNAMES); + +/** Hinweis unter dem Feld „Benutzername“ (Registrieren und Namensdialog); angezeigt wird er mit `t(USERNAME_HINT)`. */ +export const USERNAME_HINT = msg('3–20 Zeichen: Buchstaben, Zahlen, . _ -'); + +/** Obergrenze der Konten-IDs in einer Abfrage (so viele Werte nimmt ein `equal` sicher an). */ +const LOOKUP_MAX = 100; + +type TableRow = Models.Row & Record; + +function text(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +/** Der Schlüssel eines Namens (Spalte `usernameKey`): klein geschrieben. Über ihn ist ein Name nur einmal vergeben („Kenso“ = „kenso“). */ +export function usernameKey(name: string): string { + return name.toLowerCase(); +} + +/** + * Prüft einen Namen vor dem Senden: null, wenn er passt, sonst der Text für den Nutzer in der Sprache der Seite (Muster, dann Sperrliste). + * Geprüft wird genau die Zeichenkette, die übergeben wird; wer sie vom Nutzer hat, trimmt sie vorher (der Dienst tut es nicht). + */ +export function validateUsername(name: string): string | null { + if (!USERNAME_PATTERN.test(name)) return t(API_MESSAGES.usernameInvalid); + if (RESERVED.has(usernameKey(name))) return t(API_MESSAGES.usernameReserved); + return null; +} + +// ---- Namen merken und melden ------------------------------------------------------------------------------ + +/** Konto-ID → Benutzername, nur gefundene (ein Konto ohne Profil kann sich später einen Namen wählen, also wird es wieder gefragt). */ +const names = new Map(); +const listeners = new Set<(userId: string, username: string) => void>(); + +/** Merkt den Namen eines Kontos, dessen Stand diese Seite gerade selbst erfahren hat (Speichern, Lesen des eigenen Profils), und meldet ihn. */ +function announce(userId: string, username: string): void { + names.set(userId, username); + // Kopie, damit sich ein Hörer beim Melden abmelden darf; ein werfender Hörer stört weder die anderen noch das Ergebnis. + for (const fn of [...listeners]) { + try { + fn(userId, username); + } catch (err) { + console.error('Ein Hörer der Namensänderung ist gescheitert', err); + } + } +} + +/** + * Meldet, wenn der Name des angemeldeten Kontos feststeht oder sich ändert: nach dem Speichern und nach dem Lesen des eigenen Profils + * (`loadMyUsername`). Nicht gemeldet wird, was `usernamesFor` über andere erfährt. Gibt die Abmelde-Funktion zurück. + */ +export function onUsernameChange(fn: (userId: string, username: string) => void): () => void { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; +} + +/** Der Name des angemeldeten Kontos laut Zwischenspeicher; null, wenn es keinen hat, keiner angemeldet ist oder er noch nicht gelesen wurde. */ +export function myUsername(): string | null { + const userId = currentUserId(); + return userId ? (names.get(userId) ?? null) : null; +} + +// ---- Lesen -------------------------------------------------------------------------------------------------- + +/** „Es gibt kein Profil (oder keines für mich)“; eine fehlende Tabelle oder Datenbank ist dagegen ein echter Fehler. */ +function isRowMissing(err: unknown): boolean { + if (typeof err !== 'object' || err === null) return false; + const { code, type } = err as { code?: unknown; type?: unknown }; + return code === 404 && (type === 'row_not_found' || type === undefined || type === ''); +} + +/** + * Der Name eines Kontos. Hat es kein Profil, ist das Ergebnis null (Appwrite antwortet mit 404); eine ID, die keine Appwrite-ID sein + * kann, ergibt null ohne Anfrage. Andere Fehler (Netz, Server) gehen durch. + */ +export async function getUsername(userId: string): Promise { + if (!ID_PATTERN.test(userId)) return null; + try { + const row = await tables.getRow({ databaseId: IDS.database, tableId: IDS.profiles, rowId: userId }); + return text(row.username) || null; + } catch (err) { + if (isRowMissing(err)) return null; + throw err; + } +} + +/** + * Die Namen der Konten für die Anzeige „von @name“: eine Abfrage für alle IDs, die noch nicht bekannt sind (Konten ohne Profil fehlen + * in der Map). Gefundene Namen werden gemerkt. Die Anzeige braucht den Namen nur als Beiwerk: schlägt die Abfrage fehl, ist das kein + * Fehler, es bleibt bei den schon bekannten Namen (ohne die ist die Map leer), und die Aufrufer greifen auf ihre Ersatznamen zurück. + */ +export async function usernamesFor(userIds: readonly string[]): Promise> { + const found = new Map(); + const missing: string[] = []; + for (const id of new Set(userIds)) { + if (!ID_PATTERN.test(id)) continue; + const known = names.get(id); + if (known === undefined) missing.push(id); + else found.set(id, known); + } + try { + for (let from = 0; from < missing.length; from += LOOKUP_MAX) { + const ids = missing.slice(from, from + LOOKUP_MAX); + const list = await tables.listRows({ + databaseId: IDS.database, + tableId: IDS.profiles, + queries: [Query.equal('$id', ids), Query.limit(ids.length)], + }); + for (const row of list.rows) { + const name = text(row.username); + if (name === '') continue; + // Steht für das Konto inzwischen ein Name im Speicher (es wurde während der Abfrage umbenannt), ist der neuer als die Antwort. + const current = names.get(row.$id) ?? name; + names.set(row.$id, current); + found.set(row.$id, current); + } + } + } catch { + // nur Beiwerk der Anzeige, siehe oben + } + return found; +} + +/** + * Ist der Name schon an ein anderes Konto vergeben? Gefragt wird der Schlüssel (klein geschrieben), der eigene Eintrag des angemeldeten + * Kontos zählt nicht. Das ist die Vorabprüfung der Registrierung; bei zwei gleichzeitigen Anfragen entscheidet der Dienst. + * Fehler (Netz, Server) gehen durch. + */ +export async function isUsernameTaken(name: string): Promise { + const list = await tables.listRows({ + databaseId: IDS.database, + tableId: IDS.profiles, + queries: [Query.equal('usernameKey', usernameKey(name)), Query.limit(1)], + }); + const own = currentUserId(); + return list.rows.some((row) => row.$id !== own); +} + +/** + * Liest den Namen des angemeldeten Kontos (nach dem Start und nach der Anmeldung), merkt und meldet ihn. Ergebnis: der Name, null ohne + * Profil oder ohne angemeldetes Konto. Lässt sich das Profil nicht lesen (Netz, Server), wirft der Aufruf: „unbekannt“ ist nicht „kein + * Name“, und nur bei „kein Name“ wird gefragt. + */ +export async function loadMyUsername(): Promise { + const userId = currentUserId(); + if (!userId) return null; + const name = await getUsername(userId); + // Kein Profil: war es inzwischen gespeichert (Registrierung: die Abfrage war schneller als das Speichern), gilt der gemerkte Name. + if (name === null) return names.get(userId) ?? null; + announce(userId, name); + return name; +} + +// ---- Speichern ---------------------------------------------------------------------------------------------- + +/** + * Speichert den eigenen Namen (`POST /api/profil`): legt das Profil an oder ändert es, der alte Name ist danach frei. Ergebnis ist der + * Name, wie der Dienst ihn gespeichert hat. Der Zwischenspeicher gilt sofort, und alle Hörer von `onUsernameChange` erfahren es. + * Fehler sind `ApiError`: 400 `usernameInvalid`/`usernameReserved`, 409 `usernameTaken`, sonst Anmeldung, Netz, Dienst. + */ +export async function saveUsername(name: string): Promise { + const userId = currentUserId(); // das Konto, das den Aufruf stellt, auch wenn inzwischen ein anderes angemeldet ist + const res = await apiPost('/api/profil', { username: name }, { auth: true }); + const saved = res.username; + // Eine Antwort ohne Namen ist keine Antwort des Dienstes (Proxy, Fehlseite): der Ausgang ist offen. + if (typeof saved !== 'string' || saved === '') throw unavailableError(); + if (userId) announce(userId, saved); + return saved; +} + +// ---- „In dieser Sitzung schon gefragt“ --------------------------------------------------------------------- + +/** Merker in sessionStorage (Spec § 5): gilt für die Sitzung des Tabs, nicht für ein Konto. */ +const PROMPTED_KEY = 'aircard.usernamePrompted'; +/** Zusätzlich im Speicher: ist sessionStorage gesperrt, gilt „schon gefragt“ wenigstens bis die Seite neu geladen wird. */ +let promptedInMemory = false; + +/** Wurde in dieser Sitzung schon nach einem Benutzernamen gefragt? (Jeder Zugriff auf den Speicher steht in try/catch.) */ +export function usernamePrompted(): boolean { + if (promptedInMemory) return true; + try { + return sessionStorage.getItem(PROMPTED_KEY) === '1'; + } catch { + return false; + } +} + +/** Merkt, dass in dieser Sitzung nach einem Benutzernamen gefragt wird. */ +export function markUsernamePrompted(): void { + promptedInMemory = true; + try { + sessionStorage.setItem(PROMPTED_KEY, '1'); + } catch { + // gesperrt oder voll: es bleibt beim Merker im Speicher + } +} + +/** Nimmt den Merker zurück (die Registrierung hat ihn gesetzt, ist dann aber gescheitert). */ +export function clearUsernamePrompted(): void { + promptedInMemory = false; + try { + sessionStorage.removeItem(PROMPTED_KEY); + } catch { + // gesperrt: nichts zu entfernen + } +} diff --git a/web/src/account/username-dialog.test.ts b/web/src/account/username-dialog.test.ts new file mode 100644 index 0000000..5773969 --- /dev/null +++ b/web/src/account/username-dialog.test.ts @@ -0,0 +1,633 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { germanLeft } from '../i18n/english-check'; + +const mocks = vi.hoisted(() => ({ + saveUsername: vi.fn(), + loadMyUsername: vi.fn(), + /** Der Name des angemeldeten Kontos laut Zwischenspeicher (myUsername). */ + name: null as string | null, + userId: null as string | null, +})); + +vi.mock('./appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' })); +vi.mock('./auth', () => ({ currentUserId: () => mocks.userId, onAuthChange: () => () => {}, onLogout: () => () => {} })); + +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.'; + +type Dialog = typeof import('./username-dialog'); +let dialog: Dialog; +/** Der Dialog hält einen Zustand (nur einer zur Zeit): jeder Test bekommt frische Module; ApiError stammt aus derselben Kopie. */ +let ApiError: typeof import('../community/api').ApiError; +let opener: HTMLButtonElement; + +const dialogEl = () => document.querySelector('dialog.username-dialog'); +const input = () => dialogEl()!.querySelector('input[name="username"]')!; +const form = () => dialogEl()!.querySelector('form')!; +const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); +const buttonByText = (text: string) => + [...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined; +const errorEl = () => dialogEl()!.querySelector('[role="alert"]')!; +const errorText = () => errorEl().textContent; +const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent; +const heading = () => dialogEl()!.querySelector('h2')!.textContent; +const intro = () => dialogEl()!.querySelector('.username-text')!.textContent; +/** Die IDs in `aria-describedby` des Feldes. */ +const describedBy = () => (input().getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean); +const settle = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve(); +}; + +beforeEach(async () => { + mocks.saveUsername.mockReset(); + mocks.loadMyUsername.mockReset(); + mocks.name = null; + mocks.userId = null; + document.body.innerHTML = ''; + opener = document.getElementById('opener') as HTMLButtonElement; + opener.focus(); + vi.resetModules(); + // Nur Netzzugriff und Zwischenspeicher sind ersetzt; Muster, Sperrliste und die Texte bleiben echt. `doMock` nach jedem Zurücksetzen: + // ein einmal berechnetes `vi.mock` bliebe über `resetModules` hinweg bei der ersten Kopie von profile.ts und i18n.ts stehen. + vi.doMock('./profile', async () => ({ + ...(await vi.importActual('./profile')), + saveUsername: mocks.saveUsername, + loadMyUsername: mocks.loadMyUsername, + myUsername: () => mocks.name, + })); + dialog = await import('./username-dialog'); + ({ ApiError } = await import('../community/api')); +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.doUnmock('./profile'); + vi.restoreAllMocks(); +}); + +describe('Dialog „Benutzername wählen“: Aufbau', () => { + it('prompt: Überschrift, leeres und fokussiertes Feld, Hinweis, „Später“ und „Speichern“', () => { + void dialog.usernameDialog({ mode: 'prompt' }); + expect(dialogEl()?.open).toBe(true); + expect(heading()).toBe('Benutzername wählen'); + expect(intro()).toBe('Dein Benutzername ist öffentlich und steht bei deinen Logos und Karten.'); + expect(input().value).toBe(''); + expect(document.activeElement).toBe(input()); + expect(dialogEl()!.querySelector('label')!.textContent).toBe('Benutzername'); + expect(dialogEl()!.querySelector('.auth-hint')!.textContent).toBe('3–20 Zeichen: Buchstaben, Zahlen, . _ -'); + expect([...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Später', 'Speichern']); + expect(buttonByText('Speichern')!.type).toBe('submit'); + expect(buttonByText('Später')!.type).toBe('button'); + }); + + it('das Feld ist kein Anmeldefeld: keine Vorschläge des Browsers, keine Großschreibung, keine Rechtschreibprüfung', () => { + void dialog.usernameDialog({ mode: 'prompt' }); + expect(input().autocomplete).toBe('off'); + expect(input().getAttribute('autocapitalize')).toBe('none'); + expect(input().getAttribute('spellcheck')).toBe('false'); + expect(input().type).toBe('text'); + }); + + it('required: „Abbrechen“ statt „Später“ und der Hinweis, dass zuerst ein Name nötig ist', () => { + void dialog.usernameDialog({ mode: 'required' }); + expect(heading()).toBe('Benutzername wählen'); + expect(intro()).toBe('Bitte wähle zuerst einen Benutzernamen.'); + expect([...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Abbrechen', 'Speichern']); + expect(input().value).toBe(''); + }); + + it('change: das Feld ist mit dem aktuellen Namen vorbelegt (als Wert, nie als HTML), „Abbrechen“, Überschrift „Benutzername ändern“', () => { + mocks.name = 'Kenso'; + void dialog.usernameDialog({ mode: 'change' }); + expect(heading()).toBe('Benutzername ändern'); + expect(input().value).toBe('Kenso'); + expect(dialogEl()!.querySelector('b')).toBeNull(); + expect([...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Abbrechen', 'Speichern']); + expect(document.activeElement).toBe(input()); + }); + + it('change ohne bekannten Namen: wie „wählen“ mit „Abbrechen“, Feld leer', () => { + void dialog.usernameDialog({ mode: 'change' }); + expect(heading()).toBe('Benutzername wählen'); + expect(input().value).toBe(''); + expect(buttonByText('Abbrechen')).toBeTruthy(); + expect(buttonByText('Später')).toBeUndefined(); + }); + + it('prompt und required bleiben leer, auch wenn ein Name bekannt ist (vorbelegt wird nur beim Ändern)', () => { + mocks.name = 'Kenso'; + void dialog.usernameDialog({ mode: 'prompt' }); + expect(input().value).toBe(''); + }); + + it('Beschriftung und Verknüpfungen: Label gehört zum Feld, der Hinweis ist verknüpft, alle IDs sind eindeutig, die Überschrift benennt den Dialog', () => { + void dialog.usernameDialog({ mode: 'prompt' }); + const label = dialogEl()!.querySelector('label')!; + expect(label.control).toBe(input()); + expect(input().labels).toHaveLength(1); + const hint = dialogEl()!.querySelector('.auth-hint')!; + expect(describedBy()).toEqual([hint.id]); + expect(input().getAttribute('aria-required')).toBe('true'); + const ids = [...dialogEl()!.querySelectorAll('[id]')].map((n) => n.id); + expect(new Set(ids).size).toBe(ids.length); + expect(dialogEl()!.getAttribute('aria-labelledby')).toBe(dialogEl()!.querySelector('h2')!.id); + expect(errorEl().getAttribute('role')).toBe('alert'); + expect(form().hasAttribute('novalidate')).toBe(true); // geprüft wird selbst, mit Texten der Seite + }); +}); + +describe('Später, Abbrechen, Schließen', () => { + it('„Später“ löst mit null auf, speichert nichts, entfernt den Dialog und gibt den Fokus zurück', async () => { + const result = dialog.usernameDialog({ mode: 'prompt' }); + input().value = 'Kenso'; // auch ein getippter Name wird dann nicht gespeichert + buttonByText('Später')!.click(); + await expect(result).resolves.toBeNull(); + expect(mocks.saveUsername).not.toHaveBeenCalled(); + expect(dialogEl()).toBeNull(); + expect(document.activeElement).toBe(opener); + }); + + it.each(['required', 'change'] as const)('%s: „Abbrechen“ löst mit null auf und speichert nichts', async (mode) => { + const result = dialog.usernameDialog({ mode }); + buttonByText('Abbrechen')!.click(); + await expect(result).resolves.toBeNull(); + expect(mocks.saveUsername).not.toHaveBeenCalled(); + expect(dialogEl()).toBeNull(); + }); + + it('Esc (close-Ereignis des Dialogs) löst mit null auf', async () => { + const result = dialog.usernameDialog({ mode: 'prompt' }); + dialogEl()!.close(); + await expect(result).resolves.toBeNull(); + expect(dialogEl()).toBeNull(); + }); + + it('ein Klick auf den Hintergrund schließt, ein Klick im Formular oder Markieren, das außerhalb endet, nicht', async () => { + const result = dialog.usernameDialog({ mode: 'prompt' }); + input().dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + input().click(); + dialogEl()!.click(); // mousedown im Feld, click auf dem Dialog: Markieren + expect(dialogEl()?.open).toBe(true); + dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + dialogEl()!.click(); + await expect(result).resolves.toBeNull(); + expect(dialogEl()).toBeNull(); + }); + + it('signal: ein Abbruch schließt den Dialog mit null; ein schon abgebrochenes Signal öffnet gar nichts', async () => { + const controller = new AbortController(); + const result = dialog.usernameDialog({ mode: 'prompt', signal: controller.signal }); + expect(dialogEl()?.open).toBe(true); + controller.abort(); + await expect(result).resolves.toBeNull(); + expect(dialogEl()).toBeNull(); + + controller.abort(); + await expect(dialog.usernameDialog({ mode: 'prompt', signal: controller.signal })).resolves.toBeNull(); + expect(dialogEl()).toBeNull(); + }); + + it('schlägt showModal fehl, wird aufgeräumt und der Fehler gemeldet; der nächste Aufruf öffnet normal', async () => { + const spy = vi.spyOn(HTMLDialogElement.prototype, 'showModal').mockImplementationOnce(() => { + throw new Error('showModal kaputt'); + }); + await expect(dialog.usernameDialog({ mode: 'prompt' })).rejects.toThrow('showModal kaputt'); + expect(dialogEl()).toBeNull(); + spy.mockRestore(); + const again = dialog.usernameDialog({ mode: 'prompt' }); + expect(dialogEl()?.open).toBe(true); + buttonByText('Später')!.click(); + await expect(again).resolves.toBeNull(); + }); +}); + +describe('Speichern', () => { + it('speichert den getrimmten Namen, löst mit dem Namen des Dienstes auf und schließt', async () => { + mocks.saveUsername.mockResolvedValue('KenSo'); + const result = dialog.usernameDialog({ mode: 'prompt' }); + input().value = ' Kenso '; + send(); + await expect(result).resolves.toBe('KenSo'); + expect(mocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso'); + expect(dialogEl()).toBeNull(); + expect(document.activeElement).toBe(opener); + }); + + it('der Knopf „Speichern“ löst das Absenden aus (Enter im Feld ebenso: es ist ein Formular)', async () => { + mocks.saveUsername.mockResolvedValue('Kenso'); + const result = dialog.usernameDialog({ mode: 'change' }); + input().value = 'Kenso'; + expect(buttonByText('Speichern')!.closest('form')).toBe(form()); + send(); + await expect(result).resolves.toBe('Kenso'); + }); + + it('prüft vor dem Senden: Muster und Sperrliste, Text am Feld, kein Aufruf', async () => { + void dialog.usernameDialog({ mode: 'prompt' }); + const invalid = async (value: string, message: string) => { + input().value = value; + send(); + await settle(); + expect(errorText()).toBe(message); + expect(input().getAttribute('aria-invalid')).toBe('true'); + expect(describedBy()).toContain(errorEl().id); // das Feld verweist auf die Fehlerzeile + expect(document.activeElement).toBe(input()); + expect(mocks.saveUsername).not.toHaveBeenCalled(); + expect(dialogEl()?.open).toBe(true); + }; + await invalid('', INVALID); + await invalid(' ', INVALID); + await invalid('ab', INVALID); + await invalid('x'.repeat(21), INVALID); + await invalid('_abc', INVALID); + await invalid('na me', INVALID); + await invalid('Käse', INVALID); + await invalid('Admin', 'Dieser Benutzername ist nicht erlaubt.'); + await invalid('AirCard-IOS', 'Dieser Benutzername ist nicht erlaubt.'); + }); + + it('eine behobene Eingabe verliert die Markierung beim nächsten Senden; der Hinweis bleibt verknüpft', async () => { + mocks.saveUsername.mockResolvedValue('Kenso'); + const result = dialog.usernameDialog({ mode: 'prompt' }); + input().value = 'x'; + send(); + expect(describedBy()).toHaveLength(2); + input().value = 'Kenso'; + send(); + expect(input().hasAttribute('aria-invalid')).toBe(false); + expect(errorText()).toBe(''); + expect(describedBy()).toHaveLength(1); + await result; + }); + + it('vergeben (409 usernameTaken): der Text steht am Feld, der Dialog bleibt offen, ein neuer Name geht', async () => { + mocks.saveUsername.mockRejectedValueOnce(new ApiError(409, TAKEN, undefined, 'usernameTaken')); + const result = dialog.usernameDialog({ mode: 'prompt' }); + input().value = 'Kenso'; + send(); + await settle(); + expect(errorText()).toBe(TAKEN); + expect(input().getAttribute('aria-invalid')).toBe('true'); + expect(describedBy()).toContain(errorEl().id); + expect(document.activeElement).toBe(input()); + expect(dialogEl()?.open).toBe(true); + expect(input().value).toBe('Kenso'); + expect(buttonByText('Speichern')!.disabled).toBe(false); + expect(buttonByText('Später')!.disabled).toBe(false); + expect(input().readOnly).toBe(false); + + mocks.saveUsername.mockResolvedValueOnce('Kenso2'); + input().value = 'Kenso2'; + send(); + await expect(result).resolves.toBe('Kenso2'); + expect(mocks.saveUsername).toHaveBeenCalledTimes(2); + }); + + it('Muster und Sperrliste des Dienstes (400) stehen ebenfalls am Feld', async () => { + mocks.saveUsername.mockRejectedValueOnce(new ApiError(400, 'x', undefined, 'usernameReserved')); + void dialog.usernameDialog({ mode: 'prompt' }); + input().value = 'Kenso'; + send(); + await settle(); + expect(errorText()).toBe('Dieser Benutzername ist nicht erlaubt.'); + expect(input().getAttribute('aria-invalid')).toBe('true'); + + mocks.saveUsername.mockRejectedValueOnce(new ApiError(400, 'x', undefined, 'usernameInvalid')); + send(); + await settle(); + expect(errorText()).toBe(INVALID); + expect(input().getAttribute('aria-invalid')).toBe('true'); + }); + + it('Dienst nicht erreichbar: der Text steht im Dialog, das Feld gilt nicht als ungültig, der Dialog bleibt für einen neuen Versuch offen', async () => { + mocks.saveUsername.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable')); + const result = dialog.usernameDialog({ mode: 'prompt' }); + input().value = 'Kenso'; + send(); + await settle(); + expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.'); + expect(input().hasAttribute('aria-invalid')).toBe(false); + expect(describedBy()).not.toContain(errorEl().id); + expect(dialogEl()?.open).toBe(true); + expect(input().value).toBe('Kenso'); + expect(statusText()).toBe(''); + + mocks.saveUsername.mockResolvedValueOnce('Kenso'); + send(); + await expect(result).resolves.toBe('Kenso'); + }); + + it('andere Fehler: kein Netz, abgelaufene Anmeldung (401) und alles Unbekannte werden erklärt statt verschluckt', async () => { + void dialog.usernameDialog({ mode: 'prompt' }); + input().value = 'Kenso'; + + mocks.saveUsername.mockRejectedValueOnce(new TypeError('Failed to fetch')); + send(); + await settle(); + expect(errorText()).toBe('Keine Verbindung zum Server.'); + + mocks.saveUsername.mockRejectedValueOnce(Object.assign(new Error('unauthorized'), { code: 401, type: 'general_unauthorized_scope' })); + send(); + await settle(); + expect(errorText()).toBe('Bitte melde dich an.'); + + mocks.saveUsername.mockRejectedValueOnce(new ApiError(401, 'Bitte melde dich an.', undefined, 'unauthorized')); + send(); + await settle(); + expect(errorText()).toBe('Bitte melde dich an.'); + + mocks.saveUsername.mockRejectedValueOnce(new Error('???')); + send(); + await settle(); + expect(errorText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); + expect(dialogEl()?.open).toBe(true); + expect(input().hasAttribute('aria-invalid')).toBe(false); + }); + + it('der Fehler eines früheren Versuchs verschwindet beim nächsten', async () => { + mocks.saveUsername.mockRejectedValueOnce(new ApiError(409, TAKEN, undefined, 'usernameTaken')); + const result = dialog.usernameDialog({ mode: 'prompt' }); + input().value = 'Kenso'; + send(); + await settle(); + mocks.saveUsername.mockResolvedValueOnce('Kenso2'); + input().value = 'Kenso2'; + send(); + expect(errorText()).toBe(''); + expect(input().hasAttribute('aria-invalid')).toBe(false); + await result; + }); +}); + +describe('Busy-Sperre beim Speichern', () => { + it('während des Speicherns: Knöpfe und Feld gesperrt, Status sichtbar, ein zweites Absenden wird ignoriert', async () => { + let finish!: (name: string) => void; + mocks.saveUsername.mockReturnValue(new Promise((resolve) => (finish = resolve))); + const result = dialog.usernameDialog({ mode: 'prompt' }); + input().value = 'Kenso'; + send(); + expect(buttonByText('Speichern')!.disabled).toBe(true); + expect(buttonByText('Später')!.disabled).toBe(true); + expect(input().readOnly).toBe(true); + expect(form().getAttribute('aria-busy')).toBe('true'); + expect(statusText()).toBe('Wird gespeichert …'); + send(); + expect(mocks.saveUsername).toHaveBeenCalledOnce(); + finish('Kenso'); + await expect(result).resolves.toBe('Kenso'); + }); + + it('Esc und ein Klick auf den Hintergrund schließen den Dialog währenddessen nicht (Abbrechen wirkte sonst nur scheinbar)', async () => { + let finish!: (name: string) => void; + mocks.saveUsername.mockReturnValue(new Promise((resolve) => (finish = resolve))); + const result = dialog.usernameDialog({ mode: 'required' }); + input().value = 'Kenso'; + send(); + const esc = new Event('cancel', { cancelable: true }); + dialogEl()!.dispatchEvent(esc); + expect(esc.defaultPrevented).toBe(true); + dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(dialogEl()?.open).toBe(true); + finish('Kenso'); + await expect(result).resolves.toBe('Kenso'); + }); + + it('schließt der Browser den Dialog trotzdem, geht er wieder auf und löst erst mit dem echten Ergebnis auf', async () => { + let finish!: (name: string) => void; + mocks.saveUsername.mockReturnValue(new Promise((resolve) => (finish = resolve))); + let resolved: string | null | undefined; + const result = dialog.usernameDialog({ mode: 'prompt' }).then((name) => (resolved = name)); + input().value = 'Kenso'; + send(); + dialogEl()!.close(); // wie ein Browser, der Esc trotz verhindertem `cancel` auswertet + await settle(); + expect(dialogEl()?.open).toBe(true); + dialogEl()!.dispatchEvent(new Event('close')); + await settle(); + expect(resolved).toBeUndefined(); + expect(dialogEl()?.open).toBe(true); + finish('Kenso'); + await result; + expect(resolved).toBe('Kenso'); + expect(dialogEl()).toBeNull(); + }); + + it('scheitert das Speichern, nachdem der Dialog zwischendurch zuging, ist er wieder offen und zeigt den Fehler', async () => { + let fail!: (error: Error) => void; + mocks.saveUsername.mockReturnValue(new Promise((_, reject) => (fail = reject))); + void dialog.usernameDialog({ mode: 'prompt' }); + input().value = 'Kenso'; + send(); + dialogEl()!.close(); + fail(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable')); + await settle(); + expect(dialogEl()?.open).toBe(true); + expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.'); + }); + + it('nur `signal` schließt immer; eine danach eintreffende Antwort ändert nichts mehr', async () => { + let finish!: (name: string) => void; + mocks.saveUsername.mockReturnValue(new Promise((resolve) => (finish = resolve))); + const controller = new AbortController(); + const result = dialog.usernameDialog({ mode: 'prompt', signal: controller.signal }); + input().value = 'Kenso'; + send(); + controller.abort(); + await expect(result).resolves.toBeNull(); + expect(dialogEl()).toBeNull(); + finish('Kenso'); + await settle(); + expect(dialogEl()).toBeNull(); + }); +}); + +describe('ein Dialog zur Zeit', () => { + it('ein zweiter Aufruf, während einer offen ist, teilt sich Dialog und Ergebnis (keine zwei Dialoge übereinander)', async () => { + mocks.saveUsername.mockResolvedValue('Kenso'); + const first = dialog.usernameDialog({ mode: 'prompt' }); + const second = dialog.usernameDialog({ mode: 'required' }); + expect(second).toBe(first); + expect(document.querySelectorAll('dialog.username-dialog')).toHaveLength(1); + expect(heading()).toBe('Benutzername wählen'); + input().value = 'Kenso'; + send(); + await expect(second).resolves.toBe('Kenso'); + await expect(first).resolves.toBe('Kenso'); + }); + + it('nach dem Schließen öffnet der nächste Aufruf neu, ohne Reste', async () => { + const first = dialog.usernameDialog({ mode: 'prompt' }); + input().value = 'Kenso'; + buttonByText('Später')!.click(); + await first; + const second = dialog.usernameDialog({ mode: 'required' }); + expect(second).not.toBe(first); + expect(dialogEl()?.open).toBe(true); + expect(input().value).toBe(''); + expect(errorText()).toBe(''); + expect(intro()).toBe('Bitte wähle zuerst einen Benutzernamen.'); + buttonByText('Abbrechen')!.click(); + await second; + }); +}); + +describe('Englisch', () => { + beforeEach(async () => { + (await import('../i18n/i18n')).setLang('en', { persist: false }); + }); + + it('alle Texte des Dialogs sind englisch, in jedem Modus', async () => { + mocks.name = 'Kenso'; + const texts = () => [heading(), intro(), dialogEl()!.querySelector('label')!.textContent, dialogEl()!.querySelector('.auth-hint')!.textContent]; + const labels = () => [...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent); + + const prompt = dialog.usernameDialog({ mode: 'prompt' }); + expect(texts()).toEqual([ + 'Choose a username', + 'Your username is public and shown on your logos and cards.', + 'Username', + '3–20 characters: letters, numbers, . _ -', + ]); + expect(labels()).toEqual(['Later', 'Save']); + expect(germanLeft(dialogEl()!)).toEqual([]); + buttonByText('Later')!.click(); + await prompt; + + const required = dialog.usernameDialog({ mode: 'required' }); + expect(intro()).toBe('Please choose a username first.'); + expect(labels()).toEqual(['Cancel', 'Save']); + buttonByText('Cancel')!.click(); + await required; + + const change = dialog.usernameDialog({ mode: 'change' }); + expect(heading()).toBe('Change username'); + expect(input().value).toBe('Kenso'); + expect(germanLeft(dialogEl()!)).toEqual([]); + buttonByText('Cancel')!.click(); + await change; + }); + + it('Fehler und Status ebenso', async () => { + let finish!: (name: string) => void; + mocks.saveUsername.mockReturnValueOnce(new Promise((resolve) => (finish = resolve))); + const result = dialog.usernameDialog({ mode: 'prompt' }); + input().value = 'x'; + send(); + expect(errorText()).toBe( + 'The username must be 3–20 characters long, start with a letter or a number, and may only contain letters, numbers, periods, underscores and hyphens.', + ); + input().value = 'Admin'; + send(); + expect(errorText()).toBe('This username is not allowed.'); + + input().value = 'Kenso'; + send(); + expect(statusText()).toBe('Saving …'); + expect(germanLeft(dialogEl()!)).toEqual([]); + finish('Kenso'); + await result; + + mocks.saveUsername.mockRejectedValueOnce(new ApiError(409, TAKEN, undefined, 'usernameTaken')); + const again = dialog.usernameDialog({ mode: 'prompt' }); + input().value = 'Kenso'; + send(); + await settle(); + expect(errorText()).toBe('This username is already taken.'); + buttonByText('Later')!.click(); + await again; + }); +}); + +describe('requireUsername', () => { + it('ohne angemeldetes Konto gibt es nichts zu fordern: true, kein Dialog, keine Anfrage (die Anmeldung kommt mit der Aktion selbst)', async () => { + await expect(dialog.requireUsername()).resolves.toBe(true); + expect(dialogEl()).toBeNull(); + expect(mocks.loadMyUsername).not.toHaveBeenCalled(); + }); + + it('ein bekannter Name genügt: true ohne Dialog und ohne Anfrage', async () => { + mocks.userId = 'u1'; + mocks.name = 'Kenso'; + await expect(dialog.requireUsername()).resolves.toBe(true); + expect(dialogEl()).toBeNull(); + expect(mocks.loadMyUsername).not.toHaveBeenCalled(); + }); + + it('ist der Name noch nicht gelesen, wird er gelesen; hat das Konto einen, ist es true ohne Dialog', async () => { + mocks.userId = 'u1'; + mocks.loadMyUsername.mockResolvedValue('Kenso'); + await expect(dialog.requireUsername()).resolves.toBe(true); + expect(mocks.loadMyUsername).toHaveBeenCalledOnce(); + expect(dialogEl()).toBeNull(); + }); + + it('ohne Namen erscheint der Dialog „required“; nach dem Speichern ist es true', async () => { + mocks.userId = 'u1'; + mocks.loadMyUsername.mockResolvedValue(null); + mocks.saveUsername.mockResolvedValue('Kenso'); + const result = dialog.requireUsername(); + await settle(); + expect(dialogEl()?.open).toBe(true); + expect(intro()).toBe('Bitte wähle zuerst einen Benutzernamen.'); + expect(buttonByText('Abbrechen')).toBeTruthy(); + input().value = 'Kenso'; + send(); + await expect(result).resolves.toBe(true); + expect(mocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso'); + expect(dialogEl()).toBeNull(); + }); + + it('„Abbrechen“ ergibt false: ohne Namen geht es nicht weiter', async () => { + mocks.userId = 'u1'; + mocks.loadMyUsername.mockResolvedValue(null); + const result = dialog.requireUsername(); + await settle(); + buttonByText('Abbrechen')!.click(); + await expect(result).resolves.toBe(false); + expect(mocks.saveUsername).not.toHaveBeenCalled(); + expect(dialogEl()).toBeNull(); + }); + + it('Esc ergibt ebenfalls false', async () => { + mocks.userId = 'u1'; + mocks.loadMyUsername.mockResolvedValue(null); + const result = dialog.requireUsername(); + await settle(); + dialogEl()!.close(); + await expect(result).resolves.toBe(false); + }); + + it('lässt sich das Profil nicht lesen, entscheidet der Dienst (er antwortet ohne Profil mit 403): true, kein Dialog', async () => { + mocks.userId = 'u1'; + mocks.loadMyUsername.mockRejectedValue(new TypeError('Failed to fetch')); + await expect(dialog.requireUsername()).resolves.toBe(true); + expect(dialogEl()).toBeNull(); + }); + + it('läuft schon ein Namensdialog (etwa die einmalige Abfrage), teilt sich requireUsername ihn', async () => { + mocks.userId = 'u1'; + mocks.loadMyUsername.mockResolvedValue(null); + mocks.saveUsername.mockResolvedValue('Kenso'); + const prompt = dialog.usernameDialog({ mode: 'prompt' }); + const required = dialog.requireUsername(); + await settle(); + expect(document.querySelectorAll('dialog.username-dialog')).toHaveLength(1); + input().value = 'Kenso'; + send(); + await expect(required).resolves.toBe(true); + await expect(prompt).resolves.toBe('Kenso'); + }); + + it('„Später“ in einem geteilten Dialog ergibt für requireUsername false', async () => { + mocks.userId = 'u1'; + mocks.loadMyUsername.mockResolvedValue(null); + const prompt = dialog.usernameDialog({ mode: 'prompt' }); + const required = dialog.requireUsername(); + await settle(); + buttonByText('Später')!.click(); + await expect(prompt).resolves.toBeNull(); + await expect(required).resolves.toBe(false); + }); +}); diff --git a/web/src/account/username-dialog.ts b/web/src/account/username-dialog.ts new file mode 100644 index 0000000..ec289de --- /dev/null +++ b/web/src/account/username-dialog.ts @@ -0,0 +1,243 @@ +// Dialog „Benutzername wählen“ (Spec § 5): ein Feld, „Speichern“ und „Später“ bzw. „Abbrechen“. Drei Anlässe: die einmalige Abfrage nach +// dem Anmelden (`prompt`, mit „Später“), die Pflicht vor dem Hochladen und Veröffentlichen (`required`, nur „Abbrechen“) und das Ändern +// aus dem Konto-Menü (`change`, mit dem aktuellen Namen vorbelegt). Eigenes nach dem Muster von publish-dialog.ts; alle Texte +// gehen als Text in den DOM, nie als HTML. +import './account.css'; +import { ApiError, apiErrorText } from '../community/api'; +import { el, setFieldInvalid } from '../editor/dom'; +import { API_MESSAGES } from '../i18n/api-messages'; +import { msg, t } from '../i18n/i18n'; +import { currentUserId } from './auth'; +import { errorMessage, isUnauthorized } from './errors'; +import { USERNAME_HINT, loadMyUsername, myUsername, saveUsername, validateUsername } from './profile'; + +const RUNNING = msg('Wird gespeichert …'); +const PUBLIC_NOTE = msg('Dein Benutzername ist öffentlich und steht bei deinen Logos und Karten.'); + +/** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */ +const ERROR_ID = 'username-error'; +/** Fehlercodes des Dienstes, die am Namen selbst liegen: ihr Text steht am Feld, und das Feld gilt als ungültig. */ +const FIELD_CODES: ReadonlySet = new Set(['usernameInvalid', 'usernameReserved', 'usernameTaken']); + +/** `prompt`: einmalige Abfrage („Später“); `required`: ohne Namen geht es nicht weiter („Abbrechen“); `change`: aus dem Konto-Menü, vorbelegt. */ +export type UsernameDialogMode = 'prompt' | 'required' | 'change'; + +export interface UsernameDialogOptions { + mode: UsernameDialogMode; + /** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */ + signal?: AbortSignal; +} + +/** Der Text zu einem fehlgeschlagenen Speichern in der Sprache der Seite; Fehler des Dienstes über ihren Code (`apiErrorText`). */ +function failureText(err: unknown): string { + if (err instanceof ApiError) return apiErrorText(err); + if (isUnauthorized(err)) return t(API_MESSAGES.unauthorized); // die Anmeldung ist abgelaufen (schon der Abruf des JWT scheitert) + return errorMessage(err, 'save'); +} + +function build(opts: UsernameDialogOptions): Promise { + return new Promise((resolve, reject) => { + const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; + // Vorbelegt wird nur beim Ändern; kennt das Konto noch keinen Namen, ist es ein gewöhnliches „Wählen“. + const current = opts.mode === 'change' ? myUsername() : null; + let result: string | null = null; + let closed = false; + let busy = false; + + const input = el('input', { + class: 'auth-input', + type: 'text', + name: 'username', + id: 'username-name', + // Kein Anmeldefeld: der Browser soll hier nicht die gespeicherte Anmeldung (E-Mail) vorschlagen. + autocomplete: 'off', + autocapitalize: 'none', + spellcheck: 'false', + 'aria-required': 'true', + 'aria-describedby': 'username-name-hint', + }); + input.value = current ?? ''; + const field = el( + 'div', + { class: 'auth-field' }, + el('label', { class: 'auth-label', for: input.id }, t('Benutzername')), + input, + el('p', { class: 'auth-hint', id: 'username-name-hint' }, t(USERNAME_HINT)), + ); + const intro = el('p', { class: 'username-text' }, opts.mode === 'required' ? t(API_MESSAGES.usernameRequired) : t(PUBLIC_NOTE)); + const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, role: 'alert' }); + const statusEl = el('p', { class: 'auth-msg', role: 'status' }); + const cancel = el('button', { type: 'button', class: 'auth-btn' }, opts.mode === 'prompt' ? t('Später') : t('Abbrechen')); + const save = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Speichern')); + // novalidate: die Prüfung läuft selbst, mit Texten der Seite statt der des Browsers. + const form = el( + 'form', + { class: 'auth-form', novalidate: true }, + intro, + field, + errorEl, + statusEl, + el('div', { class: 'username-actions' }, cancel, save), + ); + const dialog = el( + 'dialog', + { class: 'auth-dialog username-dialog', 'aria-labelledby': 'username-heading' }, + el( + 'div', + { class: 'auth-card' }, + el('h2', { id: 'username-heading', class: 'auth-title' }, current ? t('Benutzername ändern') : t('Benutzername wählen')), + form, + ), + ); + + const finish = () => { + if (closed) return; + closed = true; + opts.signal?.removeEventListener('abort', finish); + if (dialog.open) dialog.close(); + dialog.remove(); + if (opener?.isConnected) opener.focus(); + resolve(result); + }; + + /** Öffnet den Dialog wieder, falls ihn der Browser geschlossen hat (Esc trotz verhindertem `cancel`). */ + function reopen(): boolean { + if (dialog.open) return true; + try { + dialog.showModal(); + return true; + } catch { + return false; + } + } + + function setBusy(value: boolean): void { + busy = value; + form.setAttribute('aria-busy', String(value)); + input.readOnly = value; // bleibt fokussierbar, ein Screenreader verliert den Fokus nicht + save.disabled = value; + cancel.disabled = value; + statusEl.textContent = value ? t(RUNNING) : ''; + } + + /** Ein Problem mit dem Namen: der Text steht in der Fehlerzeile, das Feld gilt als ungültig und bekommt den Fokus. */ + function showFieldProblem(text: string): void { + errorEl.textContent = text; + setFieldInvalid(input, ERROR_ID, true); + input.focus(); + } + + async function submit(): Promise { + setFieldInvalid(input, ERROR_ID, false); + errorEl.textContent = ''; + const name = input.value.trim(); // der Dienst trimmt nicht + const problem = validateUsername(name); + if (problem) { + showFieldProblem(problem); + return; + } + setBusy(true); + try { + result = await saveUsername(name); + } catch (err) { + if (closed) return; + setBusy(false); + if (err instanceof ApiError && err.serverCode !== null && FIELD_CODES.has(err.serverCode)) { + showFieldProblem(failureText(err)); // vergeben, gesperrt, ungültig: das liegt am Namen + } else { + errorEl.textContent = failureText(err); // Netz, Dienst, Anmeldung: der Name kann richtig sein + input.focus(); + } + reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein + return; + } + if (closed) return; + finish(); + } + + form.addEventListener('submit', (event) => { + event.preventDefault(); + if (busy || closed) return; + void submit(); + }); + cancel.addEventListener('click', finish); + // Esc löst `cancel` aus (abbrechbar) und danach `close`; mitten im Speichern darf der Dialog nicht zugehen. + dialog.addEventListener('cancel', (event) => { + if (busy) event.preventDefault(); + }); + // Schließt der Browser den Dialog trotzdem mitten im Speichern, wird er wieder geöffnet; aufgelöst wird erst mit dem echten Ergebnis. + dialog.addEventListener('close', () => { + if (busy && !closed) { + reopen(); + return; + } + finish(); + }); + // Hintergrund = Klick auf das selbst (die Karte füllt es ganz aus); Drücken und Loslassen müssen beide dort sein, + // sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog. + let pressedOnBackdrop = false; + dialog.addEventListener('mousedown', (event) => { + pressedOnBackdrop = event.target === dialog; + }); + dialog.addEventListener('click', (event) => { + if (event.target === dialog && pressedOnBackdrop && !busy) finish(); + pressedOnBackdrop = false; + }); + opts.signal?.addEventListener('abort', finish); + + document.body.append(dialog); + try { + dialog.showModal(); + } catch (err) { + closed = true; + opts.signal?.removeEventListener('abort', finish); + dialog.remove(); + reject(err); + return; + } + input.focus(); + if (current) input.select(); // zum Ändern: gleich überschreiben können + }); +} + +/** Der offene Dialog; es gibt höchstens einen. */ +let running: Promise | null = null; + +/** + * Öffnet den Dialog. Löst auf, sobald er geschlossen ist: mit dem gespeicherten Namen (so, wie der Dienst ihn gespeichert hat), sonst mit + * null („Später“, „Abbrechen“, Esc, Hintergrund, `signal`). Fehler erscheinen im Dialog; er bleibt dann für einen neuen Versuch offen. + * Während des Speicherns lässt er sich nicht schließen (Esc und Hintergrund wirkungslos), damit niemand glaubt, etwas abgebrochen zu haben, + * das dann doch gespeichert wird; nur `signal` schließt immer. + * Läuft schon ein Namensdialog (etwa die einmalige Abfrage, wenn gleichzeitig der Dienst „Name nötig“ meldet), teilt sich der Aufruf ihn + * samt Ergebnis, statt einen zweiten darüberzulegen; für einen Aufrufer mit „Name nötig“ ist null dann ebenso ein „nicht weiter“. + */ +export function usernameDialog(opts: UsernameDialogOptions): Promise { + if (opts.signal?.aborted) return Promise.resolve(null); + if (running) return running; + const promise: Promise = build(opts).finally(() => { + if (running === promise) running = null; + }); + running = promise; + return promise; +} + +/** + * Hat das angemeldete Konto einen Benutzernamen? Wenn nicht, fragt der Dialog `required` danach (Spec § 5: vor dem Hochladen eines Logos und + * vor dem Veröffentlichen). true: es kann weitergehen; false: der Nutzer hat „Abbrechen“ gewählt, es geschieht nichts. + * Ohne angemeldetes Konto gibt es nichts zu fordern (true): die Anmeldung kommt mit der Aktion selbst, und fehlt dem Konto dann der Name, + * antwortet der Dienst mit 403 `usernameRequired` (dann zeigt der Aufrufer den Dialog). Dasselbe gilt, wenn sich das Profil nicht lesen lässt: + * der Dienst entscheidet. + */ +export async function requireUsername(): Promise { + if (currentUserId() === null) return true; + let name = myUsername(); + if (name === null) { + try { + name = await loadMyUsername(); + } catch { + return true; + } + } + if (name !== null) return true; + return (await usernameDialog({ mode: 'required' })) !== null; +} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index d3d905a..d1f1764 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -145,6 +145,17 @@ export const EN: Readonly> = { 'Neuen Link anfordern': 'Request a new link', 'Passwort geändert. Du kannst dich jetzt anmelden.': 'Password changed. You can now sign in.', + // Benutzername: Feld beim Registrieren, Dialog „Benutzername wählen“ und Konto-Menü (account/profile.ts, account/login-dialog.ts, + // account/username-dialog.ts, account/account-menu.ts). „Abbrechen“ und „Speichern“ stehen weiter oben, die Fehlertexte zum Namen + // („schon vergeben“, „nicht erlaubt“ …) bei den Texten der API. + 'Benutzername': 'Username', + '3–20 Zeichen: Buchstaben, Zahlen, . _ -': '3–20 characters: letters, numbers, . _ -', + 'Benutzername wählen': 'Choose a username', + 'Benutzername ändern': 'Change username', + 'Dein Benutzername ist öffentlich und steht bei deinen Logos und Karten.': 'Your username is public and shown on your logos and cards.', + 'Später': 'Later', + 'Wird gespeichert …': 'Saving …', + // Fehlermeldungen zu Konto und Karten (account/errors.ts, account/auth.ts) 'Der Link ist ungültig oder abgelaufen.': 'The link is invalid or has expired.', 'E-Mail oder Passwort ist falsch.': 'Email or password is incorrect.', diff --git a/web/src/i18n/english-ui.test.ts b/web/src/i18n/english-ui.test.ts index c00f249..f5ca823 100644 --- a/web/src/i18n/english-ui.test.ts +++ b/web/src/i18n/english-ui.test.ts @@ -13,6 +13,8 @@ const mocks = vi.hoisted(() => ({ createRecovery: vi.fn(), updateRecovery: vi.fn(), }, + /** Abfrage der Benutzernamen (Tabelle profiles): leere Antwort = der Name ist frei. */ + listRows: vi.fn(), getCard: vi.fn(), saveCard: vi.fn(), getCurrentUser: vi.fn(), @@ -23,7 +25,14 @@ const mocks = vi.hoisted(() => ({ getGalleryCard: vi.fn(), })); -vi.mock('../account/appwrite', () => ({ account: mocks.acct, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' })); +vi.mock('../account/appwrite', () => ({ + account: mocks.acct, + client: {}, + tables: { listRows: mocks.listRows }, + storage: {}, + IDS: { database: 'aircard', profiles: 'profiles' }, + APPWRITE_ENDPOINT: '', +})); beforeEach(() => { vi.resetAllMocks(); @@ -80,7 +89,7 @@ describe('Konto-Menü und Login-Dialog auf Englisch', () => { expect(texts(slot)).toEqual(['Sign in']); mocks.acct.get.mockResolvedValue({ $id: 'u1', email: 'kenso@example.com' }); await auth.getCurrentUser(); - expect(texts(slot)).toEqual(expect.arrayContaining(['Account menu', 'Account', 'My cards', 'Sign out'])); + expect(texts(slot)).toEqual(expect.arrayContaining(['Account menu', 'Account', 'My cards', 'Choose a username', 'Sign out'])); expect(germanLeft(slot)).toEqual([]); mocks.acct.deleteSession.mockRejectedValue(new TypeError('Failed to fetch')); [...slot.querySelectorAll('button')].find((b) => b.textContent === 'Sign out')!.click(); @@ -111,10 +120,24 @@ describe('Konto-Menü und Login-Dialog auf Englisch', () => { button('Sign up').click(); expect(dialog().querySelector('h2')!.textContent).toBe('Sign up'); - expect(texts(dialog())).toEqual(expect.arrayContaining(['At least 8 characters.', 'Confirm password'])); + expect(texts(dialog())).toEqual( + expect.arrayContaining(['Username', '3–20 characters: letters, numbers, . _ -', 'At least 8 characters.', 'Confirm password']), + ); + expect(germanLeft(dialog())).toEqual([]); + input('username').value = 'a!'; input('email').value = 'a@b.de'; input('password').value = 'passwort123'; input('password-repeat').value = 'passwort124'; + // der Name zuerst, am Feld: Muster und Sperrliste in der Sprache der Seite + submit(dialog().querySelector('form')); + expect(alert()).toBe( + 'The username must be 3–20 characters long, start with a letter or a number, and may only contain letters, numbers, periods, underscores and hyphens.', + ); + expect(document.activeElement).toBe(input('username')); + input('username').value = 'Admin'; + submit(dialog().querySelector('form')); + expect(alert()).toBe('This username is not allowed.'); + input('username').value = 'Kenso'; submit(dialog().querySelector('form')); expect(alert()).toBe('The passwords don’t match.'); expect(document.activeElement).toBe(input('password-repeat')); @@ -125,11 +148,19 @@ describe('Konto-Menü und Login-Dialog auf Englisch', () => { expect(document.activeElement).toBe(input('password')); expect(germanLeft(dialog())).toEqual([]); - // vorhandene E-Mail: Meldung mit dem Link „Sign in“ - mocks.acct.create.mockRejectedValue(fail(409, 'user_already_exists')); + // vergebener Name: die Meldung steht am Feld, bevor ein Konto entsteht + mocks.listRows.mockResolvedValueOnce({ rows: [{ $id: 'u9', username: 'Kenso', usernameKey: 'kenso' }] }); input('password').value = 'passwort123'; input('password-repeat').value = 'passwort123'; submit(dialog().querySelector('form')); + await vi.waitFor(() => expect(alert()).toBe('This username is already taken.')); + expect(document.activeElement).toBe(input('username')); + expect(mocks.acct.create).not.toHaveBeenCalled(); + + // vorhandene E-Mail: Meldung mit dem Link „Sign in“ + mocks.listRows.mockResolvedValue({ rows: [] }); + mocks.acct.create.mockRejectedValue(fail(409, 'user_already_exists')); + submit(dialog().querySelector('form')); await vi.waitFor(() => expect(alert()).toBe('This email is already registered. Please sign in. Sign in')); button('Sign in').click(); diff --git a/web/src/main.test.ts b/web/src/main.test.ts index 85da370..5b42f8e 100644 --- a/web/src/main.test.ts +++ b/web/src/main.test.ts @@ -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('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('./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((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); + }); +}); diff --git a/web/src/main.ts b/web/src/main.ts index e1c9fca..a4f9f59 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -2,7 +2,9 @@ import '@fontsource-variable/nunito-sans'; import './styles.css'; import { mountAccountMenu, type AccountMenu } from './account/account-menu'; import { client } from './account/appwrite'; -import { getCurrentUser } from './account/auth'; +import { currentUserId, getCurrentUser, onAuthChange } from './account/auth'; +import { loadMyUsername, markUsernamePrompted, usernamePrompted } from './account/profile'; +import { usernameDialog } from './account/username-dialog'; import { DONATE_URL } from './config'; import { currentLang, onLangChange, setLang } from './i18n/i18n'; import { legacyRedirect, rerender, startRouter } from './pages/router'; @@ -64,10 +66,41 @@ function mountShell(): void { } mountShell(); +// Benutzername (Spec § 5): Den Namen des angemeldeten Kontos liest die Seite nach dem Start und nach jeder Anmeldung (das Konto-Menü zeigt +// ihn). Hat das Konto keinen, wird es einmal pro Sitzung gefragt (Merker in sessionStorage), mit „Später“. Lässt sich der Name nicht +// lesen, wird nicht gefragt: das Konto könnte einen haben. +let checkingUsernameOf: string | null = null; + +async function checkUsername(userId: string): Promise { + if (checkingUsernameOf === userId) return; // das Lesen für dieses Konto läuft schon (Start und Anmeldemeldung treffen zusammen) + checkingUsernameOf = userId; + try { + const name = await loadMyUsername(); + // Nicht fragen, wenn das Konto einen Namen hat, inzwischen abgemeldet ist, ein anderes Konto angemeldet ist (dessen Prüfung läuft für + // sich) oder in dieser Sitzung schon gefragt wurde. Der Merker wird erst jetzt gelesen: die Registrierung setzt ihn, während der Name + // gelesen wird (login-dialog.ts). + if (name !== null || currentUserId() !== userId || usernamePrompted()) return; + markUsernamePrompted(); // vor dem Dialog: solange er offen ist, fragt kein zweiter Auslöser noch einmal + await usernameDialog({ mode: 'prompt' }); + } catch (error) { + console.error('Benutzername konnte nicht geprüft werden', error); + } finally { + if (checkingUsernameOf === userId) checkingUsernameOf = null; + } +} + +// getCurrentUser und die Anmeldung melden den Nutzer über onAuthChange; der Aufruf nach getCurrentUser unten ist das Netz darunter. +onAuthChange((user) => { + if (user) void checkUsername(user.$id); +}); + // Der Anmeldestatus wird einmal beim Start geholt; schlägt das fehl (außer 401, das getCurrentUser selbst als „abgemeldet“ meldet), // gilt der Nutzer als abgemeldet. if (accountSlot) { getCurrentUser() + .then((user) => { + if (user) void checkUsername(user.$id); + }) .catch((error: unknown) => { console.error('Anmeldestatus konnte nicht geprüft werden', error); })