feat(account): Benutzername beim Registrieren, im Konto-Menü und als einmalige Abfrage

- Neu `account/profile.ts`: Muster, Sperrliste und Schlüssel (ein Test vergleicht
  sie mit `deploy/api/common.js`), Profile lesen (`getUsername`, `usernamesFor`
  gesammelt in einer Abfrage und zwischengespeichert, bei einem Fehler nur die
  bekannten Namen), `isUsernameTaken`, `saveUsername` (POST /api/profil,
  Zwischenspeicher + `onUsernameChange`), `myUsername`/`loadMyUsername` und der
  Merker `aircard.usernamePrompted`. `IDS.profiles`.
- Neu `account/username-dialog.ts`: Dialog „Benutzername wählen“ in den Modi
  prompt („Später“), required („Abbrechen“) und change (vorbelegt), Fehler am
  Feld, Busy-Sperre, höchstens ein Dialog zur Zeit; dazu `requireUsername()`.
- Registrieren: Pflichtfeld „Benutzername“ vor der E-Mail. Muster, Sperrliste und
  Verfügbarkeit werden vor dem Anlegen des Kontos geprüft (kein halbes Konto);
  danach wird der Name gespeichert, scheitert das, schließt der Login-Dialog und
  der Namensdialog fragt.
- Konto-Menü: „@name“ oben, die E-Mail klein darunter, Eintrag „Benutzername
  ändern“ bzw. „Benutzername wählen“.
- main.ts: Name nach Start und Anmeldung lesen; ohne Namen einmal pro Sitzung der
  Dialog „prompt“ (nicht, wenn der Name nicht lesbar ist).
- Texte in `en.ts`, Stile in `account.css`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 07:22:15 +00:00
1 parent 19c8151c7e
commit 3e4bc21f00
14 files changed
+2660 -40

No files matched your search

+168 -1
View File
@@ -7,6 +7,10 @@ const mocks = vi.hoisted(() => ({
listeners: new Set<(user: TestUser | null) => void>(), listeners: new Set<(user: TestUser | null) => void>(),
logout: vi.fn(), logout: vi.fn(),
requireLogin: 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', () => ({ vi.mock('./auth', () => ({
@@ -18,6 +22,14 @@ vi.mock('./auth', () => ({
}, },
})); }));
vi.mock('./login-dialog', () => ({ requireLogin: mocks.requireLogin })); 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'; import { mountAccountMenu, type AccountMenu } from './account-menu';
@@ -33,11 +45,21 @@ function setUser(user: TestUser | null) {
for (const fn of [...mocks.listeners]) fn(user); 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(() => { beforeEach(() => {
mocks.user = null; mocks.user = null;
mocks.name = null;
mocks.listeners.clear(); mocks.listeners.clear();
mocks.nameListeners.clear();
mocks.logout.mockReset(); mocks.logout.mockReset();
mocks.requireLogin.mockReset(); mocks.requireLogin.mockReset();
mocks.usernameDialog.mockReset();
mocks.usernameDialog.mockResolvedValue(null);
document.body.innerHTML = '<div id="account-slot"></div>'; document.body.innerHTML = '<div id="account-slot"></div>';
slot = document.getElementById('account-slot') as HTMLElement; slot = document.getElementById('account-slot') as HTMLElement;
menu = mountAccountMenu(slot); menu = mountAccountMenu(slot);
@@ -130,11 +152,156 @@ describe('Konto-Menü', () => {
expect(buttonByText('Anmelden')).toBeDefined(); 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' }; mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready(); menu.ready();
menu.dispose(); menu.dispose();
expect(slot.childElementCount).toBe(0); expect(slot.childElementCount).toBe(0);
expect(mocks.listeners.size).toBe(0); expect(mocks.listeners.size).toBe(0);
expect(mocks.nameListeners.size).toBe(0);
});
});
describe('Konto-Menü: Benutzername', () => {
const signedIn = () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
};
const head = () => panel()!.querySelector<HTMLElement>('.account-head')!;
const nameLine = () => panel()!.querySelector('.account-name');
const items = () => [...panel()!.querySelectorAll('.account-item')].map((item) => item.textContent);
it('mit Namen steht „@name“ oben und die E-Mail klein darunter', () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
expect(nameLine()?.textContent).toBe('@Kenso');
expect(panel()!.querySelector('.account-email')?.textContent).toBe('kenso@example.com');
expect([...head().children].map((child) => child.className)).toEqual(['account-name', 'account-email']); // Name zuerst
expect(head().parentElement).toBe(panel());
expect(head().nextElementSibling?.textContent).toBe('Meine Karten'); // die Kopfzeile steht vor den Einträgen
});
it('der Avatar bleibt bei der Initiale der E-Mail', () => {
signedIn();
mocks.name = 'Zebra';
menu.ready();
expect(avatar()?.textContent).toBe('K');
});
it('ohne Namen steht nur die E-Mail oben (wie bisher)', () => {
signedIn();
menu.ready();
expect(nameLine()).toBeNull();
expect(head().textContent).toBe('kenso@example.com');
});
it('Name und E-Mail erscheinen als Text, nie als HTML', () => {
mocks.user = { $id: 'u1', email: '<i>x</i>@example.com' };
mocks.name = '<b>Kenso</b>';
menu.ready();
expect(head().querySelector('b, i')).toBeNull();
expect(nameLine()?.textContent).toBe('@<b>Kenso</b>');
expect(nameLine()?.getAttribute('title')).toBe('@<b>Kenso</b>');
});
it('Eintrag „Benutzername ändern“ mit Namen, „Benutzername wählen“ ohne – zwischen „Meine Karten“ und „Abmelden“', () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
expect(items()).toEqual(['Meine Karten', 'Benutzername ändern', 'Abmelden']);
setUser(null);
mocks.name = null;
setUser({ $id: 'u1', email: 'kenso@example.com' });
expect(items()).toEqual(['Meine Karten', 'Benutzername wählen', 'Abmelden']);
});
it('der Eintrag öffnet den Dialog im Modus „change“ und schließt das Menü', () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
avatar()!.click();
buttonByText('Benutzername ändern')!.click();
expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'change' });
expect(panel()?.hidden).toBe(true);
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
});
it('ohne Namen öffnet „Benutzername wählen“ denselben Dialog', () => {
signedIn();
menu.ready();
buttonByText('Benutzername wählen')!.click();
expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'change' });
});
it('nach dem Dialog geht der Fokus zurück an den Avatar (der Eintrag im geschlossenen Menü kann ihn nicht halten)', async () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
avatar()!.click();
buttonByText('Benutzername ändern')!.click();
await vi.waitFor(() => expect(document.activeElement).toBe(avatar()));
});
it('ändert sich der Name (Speichern im Dialog), zeigt das Menü sofort den neuen', () => {
signedIn();
mocks.name = 'Alt';
menu.ready();
expect(nameLine()?.textContent).toBe('@Alt');
setName('u1', 'Neu');
expect(nameLine()?.textContent).toBe('@Neu');
expect(items()).toContain('Benutzername ändern');
});
it('ein erst später gelesener Name erscheint ohne weiteres Zutun (Menü steht schon, der Name kommt nach)', () => {
signedIn();
menu.ready();
expect(nameLine()).toBeNull();
expect(buttonByText('Benutzername wählen')).toBeDefined();
setName('u1', 'Kenso');
expect(nameLine()?.textContent).toBe('@Kenso');
expect(buttonByText('Benutzername ändern')).toBeDefined();
});
it('nach dem Abmelden ist das Menü unabhängig vom Namen wieder „Anmelden“', () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
setUser(null);
expect(slot.querySelector('.account-name')).toBeNull();
expect(buttonByText('Anmelden')).toBeDefined();
});
it('vor ready() und nach dispose zeichnet auch eine Namensänderung nichts', () => {
signedIn();
setName('u1', 'Kenso');
expect(slot.childElementCount).toBe(0);
menu.ready();
menu.dispose();
setName('u1', 'Neu');
expect(slot.childElementCount).toBe(0);
});
it('lässt sich der Dialog nicht öffnen, bleibt es bei einer Meldung in der Konsole (kein unbehandelter Fehler), der Fokus geht trotzdem zurück', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt'));
signedIn();
menu.ready();
avatar()!.click();
buttonByText('Benutzername wählen')!.click();
await vi.waitFor(() => expect(error).toHaveBeenCalled());
await vi.waitFor(() => expect(document.activeElement).toBe(avatar()));
error.mockRestore();
});
it('auf Englisch: Einträge in der Sprache der Seite', async () => {
(await import('../i18n/i18n')).setLang('en', { persist: false });
signedIn();
mocks.name = 'Kenso';
menu.ready();
expect(items()).toEqual(['My cards', 'Change username', 'Sign out']);
expect(nameLine()?.textContent).toBe('@Kenso');
mocks.name = null;
setUser({ $id: 'u1', email: 'kenso@example.com' });
expect(items()).toEqual(['My cards', 'Choose a username', 'Sign out']);
}); });
}); });
+25 -3
View File
@@ -1,5 +1,5 @@
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale // Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale und einem Menü (Benutzername und E-Mail,
// und einem Menü (E-Mail, „Meine Karten“, „Abmelden“). Die Texte stehen in der Sprache der Seite; nach einem Sprachwechsel // „Meine Karten“, „Benutzername ändern“, „Abmelden“). Die Texte stehen in der Sprache der Seite; nach einem Sprachwechsel
// baut main.ts das Menü neu auf. // baut main.ts das Menü neu auf.
import './account.css'; import './account.css';
import { el } from '../editor/dom'; import { el } from '../editor/dom';
@@ -7,6 +7,8 @@ import { t } from '../i18n/i18n';
import { currentUser, logout, onAuthChange } from './auth'; import { currentUser, logout, onAuthChange } from './auth';
import { errorMessage } from './errors'; import { errorMessage } from './errors';
import { requireLogin } from './login-dialog'; import { requireLogin } from './login-dialog';
import { myUsername, onUsernameChange } from './profile';
import { usernameDialog } from './username-dialog';
export interface AccountMenu { export interface AccountMenu {
/** Zeigt das Menü erst, wenn der Anmeldestatus bekannt ist (kein Aufblitzen von „Anmelden“ bei angemeldeten Nutzern). */ /** 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, 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 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 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 signOut = el('button', { type: 'button', class: 'account-item' }, t('Abmelden'));
const error = el('span', { class: 'account-error', role: 'alert' }); 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); const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': t('Konto') }, avatar, dropdown);
let isOpen = false; let isOpen = false;
@@ -79,6 +90,14 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
document.removeEventListener('keydown', onKeydown); 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<HTMLElement>('.account-avatar')?.focus());
});
signOut.addEventListener('click', () => { signOut.addEventListener('click', () => {
signOut.disabled = true; signOut.disabled = true;
error.textContent = ''; error.textContent = '';
@@ -92,6 +111,8 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
} }
const off = onAuthChange(render); 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(); render();
return { return {
@@ -101,6 +122,7 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
}, },
dispose() { dispose() {
off(); off();
offName();
detach?.(); detach?.();
detach = null; detach = null;
slot.replaceChildren(); slot.replaceChildren();
+41 -2
View File
@@ -127,18 +127,38 @@
display: none; 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; min-width: 0;
margin-bottom: 4px; margin-bottom: 4px;
padding: 6px 12px 10px; padding: 6px 12px 10px;
overflow: hidden;
border-bottom: 1px solid var(--line); 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); color: var(--muted);
font-size: 0.9rem; font-size: 0.9rem;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.account-name + .account-email {
font-size: 0.8rem; /* mit Namen steht die E-Mail klein darunter */
}
.account-item { .account-item {
appearance: none; appearance: none;
display: block; display: block;
@@ -346,6 +366,25 @@
text-align: center; 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 */ /* Seite /passwort-neu */
.reset-page { .reset-page {
+3 -1
View File
@@ -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). * 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, * `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 = { export const IDS = {
database: 'aircard', database: 'aircard',
@@ -21,6 +22,7 @@ export const IDS = {
logoLikes: 'logo_likes', logoLikes: 'logo_likes',
logoReports: 'logo_reports', logoReports: 'logo_reports',
logoBucket: 'logo-images', logoBucket: 'logo-images',
profiles: 'profiles',
} as const; } as const;
export const client: Client = new Client().setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT); export const client: Client = new Client().setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT);
+327 -3
View File
@@ -9,9 +9,22 @@ const mocks = vi.hoisted(() => ({
updateRecovery: vi.fn(), 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('./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 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'); type Dialog = typeof import('./login-dialog');
let requireLogin: Dialog['requireLogin']; let requireLogin: Dialog['requireLogin'];
@@ -19,7 +32,17 @@ let opener: HTMLButtonElement;
beforeEach(async () => { beforeEach(async () => {
for (const fn of Object.values(mocks)) fn.mockReset(); 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.resetModules();
vi.doMock('./profile', async () => ({
...(await vi.importActual<typeof import('./profile')>('./profile')),
isUsernameTaken: profileMocks.isUsernameTaken,
saveUsername: profileMocks.saveUsername,
}));
document.body.innerHTML = '<div id="account-slot"></div><button id="opener">Speichern</button>'; document.body.innerHTML = '<div id="account-slot"></div><button id="opener">Speichern</button>';
opener = document.getElementById('opener') as HTMLButtonElement; opener = document.getElementById('opener') as HTMLButtonElement;
opener.focus(); opener.focus();
@@ -51,6 +74,7 @@ describe('requireLogin: Öffnen und Schließen', () => {
it('startet auf Wunsch mit Registrieren oder Passwort vergessen', () => { it('startet auf Wunsch mit Registrieren oder Passwort vergessen', () => {
void requireLogin('register'); void requireLogin('register');
expect(title()).toBe('Registrieren'); expect(title()).toBe('Registrieren');
expect(input('username').autocomplete).toBe('username');
expect(input('password').autocomplete).toBe('new-password'); expect(input('password').autocomplete).toBe('new-password');
expect(input('password-repeat').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', () => { it('prüft vor dem Senden: Abgleich, dann Länge', () => {
void requireLogin('register'); 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(); submit();
expect(alertText()).toBe('Die Passwörter stimmen nicht überein.'); expect(alertText()).toBe('Die Passwörter stimmen nicht überein.');
fill({ password: 'kurz', 'password-repeat': 'kurz' }); fill({ password: 'kurz', 'password-repeat': 'kurz' });
submit(); submit();
expect(alertText()).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.'); expect(alertText()).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
expect(mocks.create).not.toHaveBeenCalled(); 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 () => { it('legt das Konto an, meldet an und löst mit true auf', async () => {
@@ -227,7 +252,7 @@ describe('Registrieren', () => {
mocks.createEmailPasswordSession.mockResolvedValue({}); mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' }); mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
const result = requireLogin('register'); 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(); submit();
await expect(result).resolves.toBe(true); await expect(result).resolves.toBe(true);
expect(mocks.create).toHaveBeenCalledTimes(1); expect(mocks.create).toHaveBeenCalledTimes(1);
@@ -237,7 +262,7 @@ describe('Registrieren', () => {
it('bei vorhandener E-Mail steht die Meldung mit einem Link „Anmelden“', async () => { it('bei vorhandener E-Mail steht die Meldung mit einem Link „Anmelden“', async () => {
mocks.create.mockRejectedValue(fail(409, 'user_already_exists')); mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
void requireLogin('register'); 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(); submit();
await vi.waitFor(() => expect(alertText()).toContain('Diese E-Mail ist schon registriert. Melde dich an.')); await vi.waitFor(() => expect(alertText()).toContain('Diese E-Mail ist schon registriert. Melde dich an.'));
const link = dialogEl().querySelector<HTMLButtonElement>('[role="alert"] button')!; const link = dialogEl().querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('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<boolean>((resolve) => (finishCheck = resolve)));
const result = requireLogin('register');
fill(fields());
submit();
submit();
expect(profileMocks.isUsernameTaken).toHaveBeenCalledTimes(1);
expect(dialogEl().querySelector<HTMLButtonElement>('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<boolean>((resolve) => (finishCheck = resolve)));
const result = requireLogin('register');
fill(fields());
submit();
dialogEl().querySelector<HTMLButtonElement>('.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<boolean>((resolve) => (finishCheck = resolve)));
const result = requireLogin('register');
fill(fields());
submit();
dialogEl().querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.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', () => { describe('Passwort vergessen', () => {
it('führt vom Anmelden zur E-Mail-Eingabe und sendet den Link', async () => { it('führt vom Anmelden zur E-Mail-Eingabe und sendet den Link', async () => {
mocks.createRecovery.mockResolvedValue({}); mocks.createRecovery.mockResolvedValue({});
+97 -18
View File
@@ -1,9 +1,20 @@
// Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren und „Passwort vergessen“. // Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren (mit Benutzernamen) und „Passwort vergessen“.
import './account.css'; 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 { msg, t } from '../i18n/i18n';
import { login, register, requestPasswordReset, validatePassword } from './auth'; import { login, register, requestPasswordReset, validatePassword } from './auth';
import { errorMessage, errorType, type ErrorContext } from './errors'; 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'; export type LoginView = 'login' | 'register' | 'forgot';
@@ -15,6 +26,8 @@ const TITLES: Record<LoginView, string> = {
const MISMATCH = msg('Die Passwörter stimmen nicht überein.'); const MISMATCH = msg('Die Passwörter stimmen nicht überein.');
const RESET_SENT = msg('Wir haben dir einen Link geschickt. Er gilt eine Stunde.'); 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 }; type Field = { label: HTMLLabelElement; input: HTMLInputElement };
@@ -29,6 +42,7 @@ type Panel = {
function createLoginDialog(): (view: LoginView) => Promise<boolean> { function createLoginDialog(): (view: LoginView) => Promise<boolean> {
let view: LoginView = 'login'; let view: LoginView = 'login';
let draftEmail = ''; let draftEmail = '';
let draftUsername = '';
let busy = false; let busy = false;
// Zählt jedes Öffnen/Schließen; eine Antwort, die nach dem Schließen eintrifft, wird verworfen. // Zählt jedes Öffnen/Schließen; eine Antwort, die nach dem Schließen eintrifft, wird verworfen.
let session = 0; let session = 0;
@@ -90,7 +104,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
function field( function field(
labelText: string, labelText: string,
name: string, name: string,
type: 'email' | 'password', type: 'text' | 'email' | 'password',
autocomplete: string, autocomplete: string,
value = '', value = '',
describedBy?: string, describedBy?: string,
@@ -102,8 +116,9 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
autocomplete, autocomplete,
value, value,
required: true, required: true,
autocapitalize: type === 'email' ? 'none' : null, // E-Mail-Adresse und Benutzername: keine automatische Großschreibung, keine Rechtschreibprüfung
spellcheck: type === 'email' ? 'false' : null, autocapitalize: type === 'password' ? null : 'none',
spellcheck: type === 'password' ? null : 'false',
'aria-describedby': describedBy, 'aria-describedby': describedBy,
}); });
const label = el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, labelText), input); const label = el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, labelText), input);
@@ -162,25 +177,35 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
panel.errorEl.replaceChildren(text, ...(extra ? [' ', extra] : [])); 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 { function showOk(panel: Panel, text: string): void {
panel.errorEl.textContent = ''; panel.errorEl.textContent = '';
panel.okEl.textContent = text; 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( async function run(
panel: Panel, panel: Panel,
ctx: ErrorContext, ctx: ErrorContext,
action: () => Promise<void>, action: () => Promise<HTMLElement | void>,
onError?: (err: unknown) => Node | undefined, onError?: (err: unknown) => Node | undefined,
): Promise<void> { ): Promise<void> {
const mine = session; const mine = session;
const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null; let focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
setBusy(panel, true); setBusy(panel, true);
panel.errorEl.textContent = ''; panel.errorEl.textContent = '';
panel.okEl.textContent = ''; panel.okEl.textContent = '';
try { try {
await action(); focused = (await action()) ?? focused;
} catch (err) { } catch (err) {
if (mine !== session) return; if (mine !== session) return;
showError(panel, errorMessage(err, ctx), onError?.(err)); showError(panel, errorMessage(err, ctx), onError?.(err));
@@ -190,8 +215,51 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
if (focused?.isConnected && dialog.contains(focused)) focused.focus(); 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<HTMLElement | void> {
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 { 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 okEl = el('p', { class: 'auth-msg auth-msg-ok', role: 'status' });
const form = el('form', { class: 'auth-form' }); const form = el('form', { class: 'auth-form' });
const submit = (text: string) => el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, text); const submit = (text: string) => el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, text);
@@ -215,17 +283,28 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
} }
if (forView === 'register') { 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 email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
const password = field(t('Passwort'), 'password', 'password', 'new-password', '', 'auth-pw-hint'); const password = field(t('Passwort'), 'password', 'password', 'new-password', '', 'auth-pw-hint');
const repeat = field(t('Passwort wiederholen'), 'password-repeat', 'password', 'new-password'); 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.')); 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'))); form.append(username.label, nameHint, email.label, password.label, hint, repeat.label, errorEl, okEl, submit(t('Registrieren')));
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl }; // 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) => { form.addEventListener('submit', (event) => {
event.preventDefault(); event.preventDefault();
if (busy) return; 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(); 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 mismatch = password.input.value !== repeat.input.value;
const problem = mismatch ? t(MISMATCH) : validatePassword(password.input.value); const problem = mismatch ? t(MISMATCH) : validatePassword(password.input.value);
if (problem) { if (problem) {
@@ -236,10 +315,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
void run( void run(
panel, panel,
'register', 'register',
async () => { () => registerWithName(panel, username.input, name, address, password.input.value),
await register(address, password.input.value);
finish(true);
},
(err) => { (err) => {
if (errorType(err) !== 'user_already_exists') return undefined; if (errorType(err) !== 'user_already_exists') return undefined;
return linkButton(t('Anmelden'), () => showView('login')); return linkButton(t('Anmelden'), () => showView('login'));
@@ -267,9 +343,11 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
/** Zeigt eine Ansicht; `focus` bestimmt, ob danach das erste Feld oder der gewählte Reiter den Fokus bekommt. */ /** 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 { 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<HTMLInputElement>('input[name="email"]'); const typed = content.querySelector<HTMLInputElement>('input[name="email"]');
if (typed) draftEmail = typed.value.trim(); if (typed) draftEmail = typed.value.trim();
const typedName = content.querySelector<HTMLInputElement>('input[name="username"]');
if (typedName) draftUsername = typedName.value.trim();
view = next; view = next;
title.textContent = t(TITLES[next]); title.textContent = t(TITLES[next]);
const panel = buildPanel(next); const panel = buildPanel(next);
@@ -287,6 +365,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
if (pending) return pending; if (pending) return pending;
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
draftEmail = ''; draftEmail = '';
draftUsername = '';
busy = false; busy = false;
pending = new Promise<boolean>((resolve) => { pending = new Promise<boolean>((resolve) => {
settle = resolve; settle = resolve;
+554
View File
@@ -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<typeof import('../community/api')>()),
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<typeof import('./appwrite')>('./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<typeof ApiError>).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();
}
});
});
+256
View File
@@ -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<string, unknown>;
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<string, string>();
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<string | null> {
if (!ID_PATTERN.test(userId)) return null;
try {
const row = await tables.getRow<TableRow>({ 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<Map<string, string>> {
const found = new Map<string, string>();
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<TableRow>({
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<boolean> {
const list = await tables.listRows<TableRow>({
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<string | null> {
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<string> {
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
}
}
+633
View File
@@ -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<HTMLDialogElement>('dialog.username-dialog');
const input = () => dialogEl()!.querySelector<HTMLInputElement>('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<HTMLElement>('[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 = '<button id="opener">Konto</button>';
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<typeof import('./profile')>('./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 = '<b>Kenso</b>';
void dialog.usernameDialog({ mode: 'change' });
expect(heading()).toBe('Benutzername ändern');
expect(input().value).toBe('<b>Kenso</b>');
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<string>((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<string>((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<string>((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<string>((_, 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<string>((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<string>((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);
});
});
+243
View File
@@ -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 <dialog> 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<string> = 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<string | null> {
return new Promise<string | null>((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<void> {
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 <dialog> 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<string | null> | 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<string | null> {
if (opts.signal?.aborted) return Promise.resolve(null);
if (running) return running;
const promise: Promise<string | null> = 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<boolean> {
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;
}
+11
View File
@@ -145,6 +145,17 @@ export const EN: Readonly<Record<string, string>> = {
'Neuen Link anfordern': 'Request a new link', 'Neuen Link anfordern': 'Request a new link',
'Passwort geändert. Du kannst dich jetzt anmelden.': 'Password changed. You can now sign in.', '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) // 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.', '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.', 'E-Mail oder Passwort ist falsch.': 'Email or password is incorrect.',
+36 -5
View File
@@ -13,6 +13,8 @@ const mocks = vi.hoisted(() => ({
createRecovery: vi.fn(), createRecovery: vi.fn(),
updateRecovery: vi.fn(), updateRecovery: vi.fn(),
}, },
/** Abfrage der Benutzernamen (Tabelle profiles): leere Antwort = der Name ist frei. */
listRows: vi.fn(),
getCard: vi.fn(), getCard: vi.fn(),
saveCard: vi.fn(), saveCard: vi.fn(),
getCurrentUser: vi.fn(), getCurrentUser: vi.fn(),
@@ -23,7 +25,14 @@ const mocks = vi.hoisted(() => ({
getGalleryCard: vi.fn(), 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(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
@@ -80,7 +89,7 @@ describe('Konto-Menü und Login-Dialog auf Englisch', () => {
expect(texts(slot)).toEqual(['Sign in']); expect(texts(slot)).toEqual(['Sign in']);
mocks.acct.get.mockResolvedValue({ $id: 'u1', email: 'kenso@example.com' }); mocks.acct.get.mockResolvedValue({ $id: 'u1', email: 'kenso@example.com' });
await auth.getCurrentUser(); 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([]); expect(germanLeft(slot)).toEqual([]);
mocks.acct.deleteSession.mockRejectedValue(new TypeError('Failed to fetch')); mocks.acct.deleteSession.mockRejectedValue(new TypeError('Failed to fetch'));
[...slot.querySelectorAll('button')].find((b) => b.textContent === 'Sign out')!.click(); [...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(); button('Sign up').click();
expect(dialog().querySelector('h2')!.textContent).toBe('Sign up'); 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('email').value = 'a@b.de';
input('password').value = 'passwort123'; input('password').value = 'passwort123';
input('password-repeat').value = 'passwort124'; 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')); submit(dialog().querySelector('form'));
expect(alert()).toBe('The passwords don’t match.'); expect(alert()).toBe('The passwords don’t match.');
expect(document.activeElement).toBe(input('password-repeat')); 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(document.activeElement).toBe(input('password'));
expect(germanLeft(dialog())).toEqual([]); expect(germanLeft(dialog())).toEqual([]);
// vorhandene E-Mail: Meldung mit dem Link „Sign in“ // vergebener Name: die Meldung steht am Feld, bevor ein Konto entsteht
mocks.acct.create.mockRejectedValue(fail(409, 'user_already_exists')); mocks.listRows.mockResolvedValueOnce({ rows: [{ $id: 'u9', username: 'Kenso', usernameKey: 'kenso' }] });
input('password').value = 'passwort123'; input('password').value = 'passwort123';
input('password-repeat').value = 'passwort123'; input('password-repeat').value = 'passwort123';
submit(dialog().querySelector('form')); 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')); await vi.waitFor(() => expect(alert()).toBe('This email is already registered. Please sign in. Sign in'));
button('Sign in').click(); button('Sign in').click();
+232 -6
View File
@@ -1,12 +1,19 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 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. // 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 mocks = vi.hoisted(() => {
const page = () => vi.fn((..._args: unknown[]) => vi.fn()); // baut eine Seite auf und gibt deren Aufräum-Funktion zurück const page = () => vi.fn((..._args: unknown[]) => vi.fn()); // baut eine Seite auf und gibt deren Aufräum-Funktion zurück
return { return {
setLocale: vi.fn(), setLocale: vi.fn(),
getCurrentUser: 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() }, 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/appwrite', () => ({ client: { setLocale: mocks.setLocale } }));
vi.mock('./account/auth', () => ({ vi.mock('./account/auth', () => ({
getCurrentUser: mocks.getCurrentUser, getCurrentUser: mocks.getCurrentUser,
currentUser: () => null, currentUser: () => mocks.user,
currentUserId: () => mocks.user?.$id ?? null,
logout: vi.fn(), logout: vi.fn(),
onAuthChange: (fn: (user: null) => void) => { onAuthChange: (fn: (user: TestUser | null) => void) => {
mocks.authListeners.add(fn); mocks.authListeners.add(fn);
return () => mocks.authListeners.delete(fn); return () => mocks.authListeners.delete(fn);
}, },
})); }));
vi.mock('./account/login-dialog', () => ({ requireLogin: vi.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/auth-reset', () => ({ bindEditorToAuth: vi.fn() }));
vi.mock('./editor/editor-page', () => ({ mountEditor: mocks.pages.editor })); vi.mock('./editor/editor-page', () => ({ mountEditor: mocks.pages.editor }));
vi.mock('./pages/cards-page', () => ({ mountCardsPage: mocks.pages.cards })); 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 DE_FOOTER = 'Inoffizielle Community-Seite für die App AirCard-iOS';
const EN_FOOTER = 'Unofficial community site for the AirCard-iOS app'; 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 slot = () => document.getElementById('account-slot') as HTMLElement;
const langButton = () => document.querySelector<HTMLButtonElement>('button.lang-btn')!; const langButton = () => document.querySelector<HTMLButtonElement>('button.lang-btn')!;
const navTexts = () => [...document.querySelectorAll('nav.app-nav a[data-link]')].map((a) => a.textContent); 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). */ /** 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') { async function start(path = '/', storedLang: string | null = 'de') {
vi.resetModules(); vi.resetModules();
// Das Profil: nur das Lesen im Netz ist ersetzt. Neu nach jedem Zurücksetzen, damit jede Kopie von main.ts ihren eigenen Merker
// „schon gefragt“ im Speicher hat (ein `vi.mock` bliebe bei der ersten Kopie von profile.ts stehen).
vi.doMock('./account/profile', async () => ({
...(await vi.importActual<typeof import('./account/profile')>('./account/profile')),
loadMyUsername: mocks.loadMyUsername,
}));
localStorage.clear(); localStorage.clear();
if (storedLang) localStorage.setItem('aircard.lang', storedLang); if (storedLang) localStorage.setItem('aircard.lang', storedLang);
history.replaceState(null, '', path); history.replaceState(null, '', path);
@@ -55,7 +73,11 @@ async function start(path = '/', storedLang: string | null = 'de') {
beforeEach(() => { beforeEach(() => {
mocks.setLocale.mockReset(); mocks.setLocale.mockReset();
mocks.getCurrentUser.mockReset().mockResolvedValue(null); // Gast: der Anmeldestatus ist „abgemeldet“ mocks.getCurrentUser.mockReset().mockResolvedValue(null); // Gast: der Anmeldestatus ist „abgemeldet“
mocks.user = null;
mocks.authListeners.clear(); 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(); 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 () => { it('das Konto-Menü wird neu gebaut und zeigt sich sofort, wenn der Anmeldestatus schon bekannt ist', async () => {
await start('/'); await start('/');
await vi.waitFor(() => expect(signIn()).toBeDefined()); await vi.waitFor(() => expect(signIn()).toBeDefined());
expect(mocks.authListeners.size).toBe(1); expect(mocks.authListeners.size).toBe(AUTH_LISTENERS);
langButton().click(); langButton().click();
expect(signIn('Sign in')).toBeDefined(); // kein leerer Platz bis zur nächsten Änderung, jetzt auf Englisch 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 expect(mocks.getCurrentUser).toHaveBeenCalledTimes(1); // der Status wird nicht noch einmal geholt
}); });
@@ -187,7 +209,7 @@ describe('Sprachwechsel', () => {
langButton().click(); langButton().click();
expect(slot().childElementCount).toBe(0); expect(slot().childElementCount).toBe(0);
expect(mocks.authListeners.size).toBe(1); expect(mocks.authListeners.size).toBe(AUTH_LISTENERS);
answer(null); answer(null);
await vi.waitFor(() => expect(signIn('Sign in')).toBeDefined()); await vi.waitFor(() => expect(signIn('Sign in')).toBeDefined());
@@ -214,3 +236,207 @@ describe('Sprachwechsel', () => {
expect(document.activeElement).toBe(document.body); expect(document.activeElement).toBe(document.body);
}); });
}); });
describe('Benutzername abfragen (Spec § 5)', () => {
const kenso: TestUser = { $id: 'u1', email: 'kenso@example.com' };
const PROMPTED_KEY = 'aircard.usernamePrompted';
/** Das Konto ist schon beim Start angemeldet (Sitzung im Browser). */
const sessionAtStart = (user: TestUser = kenso) => {
mocks.user = user;
mocks.getCurrentUser.mockResolvedValue(user);
};
/** Der Nutzer meldet sich an: erst steht er fest, dann hören es alle Abonnenten (wie auth.ts). */
const userSignsIn = (user: TestUser = kenso) => {
mocks.user = user;
for (const fn of [...mocks.authListeners]) fn(user);
};
const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
it('Start mit Sitzung, Konto ohne Namen: der Dialog „prompt“ erscheint genau einmal; der Merker liegt in sessionStorage', async () => {
sessionAtStart();
await start('/');
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
expect(mocks.usernameDialog).toHaveBeenCalledWith({ mode: 'prompt' });
expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1');
await settle();
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
});
it('der Merker steht schon, wenn der Dialog erscheint (ein zweiter Auslöser, solange er offen ist, fragt nicht noch einmal)', async () => {
let flagWhenShown: string | null | undefined;
let close!: () => void;
mocks.usernameDialog.mockImplementation(() => {
flagWhenShown = sessionStorage.getItem(PROMPTED_KEY);
return new Promise<null>((resolve) => (close = () => resolve(null)));
});
sessionAtStart();
await start('/');
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
expect(flagWhenShown).toBe('1');
userSignsIn(); // während der Dialog offen ist
await settle();
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
close();
await settle();
userSignsIn(); // auch nach „Später“
await settle();
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
});
it('Start mit Sitzung, Konto mit Namen: kein Dialog, der Merker bleibt leer', async () => {
mocks.loadMyUsername.mockResolvedValue('Kenso');
sessionAtStart();
await start('/');
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalled());
await settle();
expect(mocks.usernameDialog).not.toHaveBeenCalled();
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
});
it('Gast: nichts wird gelesen und nichts gefragt', async () => {
await start('/');
await vi.waitFor(() => expect(signIn()).toBeDefined());
await settle();
expect(mocks.loadMyUsername).not.toHaveBeenCalled();
expect(mocks.usernameDialog).not.toHaveBeenCalled();
});
it('Anmeldung nach dem Start, Konto ohne Namen: „prompt“ genau einmal; eine weitere Anmeldung in derselben Sitzung fragt nicht noch einmal', async () => {
await start('/');
await vi.waitFor(() => expect(signIn()).toBeDefined());
userSignsIn();
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
expect(mocks.usernameDialog).toHaveBeenCalledWith({ mode: 'prompt' });
userSignsIn({ $id: 'u1', email: 'kenso@example.com' }); // z. B. ein Labelwechsel des Kontos
await settle();
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
});
it('Anmeldung, Konto mit Namen: kein Dialog', async () => {
mocks.loadMyUsername.mockResolvedValue('Kenso');
await start('/');
userSignsIn();
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1));
await settle();
expect(mocks.usernameDialog).not.toHaveBeenCalled();
});
it('Abmelden löst nichts aus', async () => {
mocks.loadMyUsername.mockResolvedValue('Kenso');
sessionAtStart();
await start('/');
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1));
mocks.user = null;
for (const fn of [...mocks.authListeners]) fn(null);
await settle();
expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1);
expect(mocks.usernameDialog).not.toHaveBeenCalled();
});
it('Start und Anmeldemeldung für dasselbe Konto zugleich (wie im echten Start): ein Lesen, ein Dialog', async () => {
mocks.loadMyUsername.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(null), 5)));
mocks.getCurrentUser.mockImplementation(async () => {
userSignsIn(); // getCurrentUser meldet den neuen Nutzer an alle Abonnenten, bevor es ihn zurückgibt
return kenso;
});
await start('/');
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
await settle();
expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1);
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
});
it('Neuladen in derselben Sitzung: der Merker aus sessionStorage hält die Abfrage zurück, der Name wird trotzdem gelesen', async () => {
sessionAtStart();
await start('/');
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
mocks.authListeners.clear(); // die erste Seite ist weg
mocks.loadMyUsername.mockClear();
await start('/');
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1));
await settle();
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
});
it('eine neue Sitzung (anderer Tab) fragt wieder', async () => {
sessionAtStart();
await start('/');
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
sessionStorage.clear(); // neuer Tab = neue sessionStorage
mocks.authListeners.clear();
await start('/');
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(2));
});
it('der Name lässt sich nicht lesen: kein Dialog (das Konto könnte einen haben), kein Merker, kein Absturz', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mocks.loadMyUsername.mockRejectedValue(new TypeError('Failed to fetch'));
sessionAtStart();
await start('/');
await vi.waitFor(() => expect(error).toHaveBeenCalled());
expect(mocks.usernameDialog).not.toHaveBeenCalled();
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
expect(slot().childElementCount).toBeGreaterThan(0); // die Seite steht
// Beim nächsten Versuch (neue Anmeldemeldung) wird gefragt, wenn der Name lesbar ist.
mocks.loadMyUsername.mockResolvedValue(null);
userSignsIn();
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
error.mockRestore();
});
it('scheitert der Dialog beim Öffnen, bleibt es bei einer Meldung in der Konsole', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt'));
sessionAtStart();
await start('/');
await vi.waitFor(() => expect(error).toHaveBeenCalled());
expect(slot().childElementCount).toBeGreaterThan(0);
error.mockRestore();
});
it('wird der Nutzer abgemeldet, während sein Name gelesen wird, erscheint kein Dialog', async () => {
let answer!: (name: string | null) => void;
mocks.loadMyUsername.mockReturnValue(new Promise((resolve) => (answer = resolve)));
await start('/');
userSignsIn();
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1));
mocks.user = null; // abgemeldet, bevor die Antwort da ist
answer(null);
await settle();
expect(mocks.usernameDialog).not.toHaveBeenCalled();
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
});
it('wechselt das Konto, während gelesen wird, wird das andere Konto gefragt, nicht das alte', async () => {
const mia: TestUser = { $id: 'u2', email: 'mia@example.com' };
const answers: Array<(name: string | null) => void> = [];
mocks.loadMyUsername.mockImplementation(() => new Promise((resolve) => answers.push(resolve)));
await start('/');
userSignsIn(kenso);
await vi.waitFor(() => expect(answers).toHaveLength(1));
userSignsIn(mia); // Kontowechsel, bevor die erste Antwort da ist
await vi.waitFor(() => expect(answers).toHaveLength(2));
answers[0](null); // die Antwort für das alte Konto: ohne Wirkung
await settle();
expect(mocks.usernameDialog).not.toHaveBeenCalled();
answers[1](null);
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
});
it('ist sessionStorage gesperrt, wirft nichts, und gefragt wird einmal pro Seitenaufruf', async () => {
const denied = () => {
throw new DOMException('blockiert', 'SecurityError');
};
vi.stubGlobal('sessionStorage', { getItem: denied, setItem: denied, removeItem: denied });
sessionAtStart();
await start('/');
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
userSignsIn();
await settle();
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
});
});
+34 -1
View File
@@ -2,7 +2,9 @@ import '@fontsource-variable/nunito-sans';
import './styles.css'; import './styles.css';
import { mountAccountMenu, type AccountMenu } from './account/account-menu'; import { mountAccountMenu, type AccountMenu } from './account/account-menu';
import { client } from './account/appwrite'; 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 { DONATE_URL } from './config';
import { currentLang, onLangChange, setLang } from './i18n/i18n'; import { currentLang, onLangChange, setLang } from './i18n/i18n';
import { legacyRedirect, rerender, startRouter } from './pages/router'; import { legacyRedirect, rerender, startRouter } from './pages/router';
@@ -64,10 +66,41 @@ function mountShell(): void {
} }
mountShell(); 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<void> {
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), // 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. // gilt der Nutzer als abgemeldet.
if (accountSlot) { if (accountSlot) {
getCurrentUser() getCurrentUser()
.then((user) => {
if (user) void checkUsername(user.$id);
})
.catch((error: unknown) => { .catch((error: unknown) => {
console.error('Anmeldestatus konnte nicht geprüft werden', error); console.error('Anmeldestatus konnte nicht geprüft werden', error);
}) })