diff --git a/web/package-lock.json b/web/package-lock.json index 8723d37..f1a916f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,7 +8,8 @@ "name": "aircard-web", "version": "0.1.0", "dependencies": { - "@fontsource-variable/nunito-sans": "5.3.0" + "@fontsource-variable/nunito-sans": "5.3.0", + "appwrite": "22.4.1" }, "devDependencies": { "happy-dom": "20.14.5", @@ -434,6 +435,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/appwrite": { + "version": "22.4.1", + "resolved": "https://registry.npmjs.org/appwrite/-/appwrite-22.4.1.tgz", + "integrity": "sha512-NlEgUvSo7A1m+TEHZ8zlWUIuCDtNg3VN6Mbxd6r+1618CiWokig630FpeKQ9/WD5Io92QxuTV/dVzBLPlQghqg==", + "license": "BSD-3-Clause", + "dependencies": { + "json-bigint": "1.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -444,6 +454,15 @@ "node": ">=12" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-image-size": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", @@ -569,6 +588,15 @@ "node": ">=20.0.0" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", diff --git a/web/package.json b/web/package.json index ef2a10d..0b9e986 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,8 @@ "preview": "vite preview" }, "dependencies": { - "@fontsource-variable/nunito-sans": "5.3.0" + "@fontsource-variable/nunito-sans": "5.3.0", + "appwrite": "22.4.1" }, "devDependencies": { "happy-dom": "20.14.5", diff --git a/web/src/account/account-menu.ts b/web/src/account/account-menu.ts new file mode 100644 index 0000000..08a05e5 --- /dev/null +++ b/web/src/account/account-menu.ts @@ -0,0 +1,64 @@ +// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet E-Mail und „Abmelden“. +import './account.css'; +import { el } from '../editor/dom'; +import { currentUser, logout, onAuthChange } from './auth'; +import { errorMessage } from './errors'; +import { requireLogin } from './login-dialog'; + +export interface AccountMenu { + /** Zeigt das Menü erst, wenn der Anmeldestatus bekannt ist (kein Aufblitzen von „Anmelden“ bei angemeldeten Nutzern). */ + ready(): void; + dispose(): void; +} + +export function mountAccountMenu(slot: HTMLElement): AccountMenu { + let isReady = false; + + function render(): void { + if (!isReady) { + slot.replaceChildren(); + return; + } + const user = currentUser(); + if (!user) { + const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Anmelden'); + signIn.addEventListener('click', () => void requireLogin('login')); + slot.replaceChildren(el('div', { class: 'account-menu' }, signIn)); + return; + } + const email = el('span', { class: 'account-email', title: user.email }, user.email); + const signOut = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Abmelden'); + const error = el('span', { class: 'account-error', role: 'alert' }); + signOut.addEventListener('click', () => { + signOut.disabled = true; + error.textContent = ''; + // Bei Erfolg zeichnet onAuthChange das Menü neu; nur der Fehlerfall braucht Handarbeit. + logout().catch((err: unknown) => { + error.textContent = errorMessage(err, 'load'); + signOut.disabled = false; + }); + }); + slot.replaceChildren( + el( + 'div', + { class: 'account-menu', role: 'group', 'aria-label': 'Konto' }, + el('div', { class: 'account-row' }, email, signOut), + error, + ), + ); + } + + const off = onAuthChange(render); + render(); + + return { + ready() { + isReady = true; + render(); + }, + dispose() { + off(); + slot.replaceChildren(); + }, + }; +} diff --git a/web/src/account/account.css b/web/src/account/account.css new file mode 100644 index 0000000..f18d0f4 --- /dev/null +++ b/web/src/account/account.css @@ -0,0 +1,307 @@ +/* Konto: Login-Dialog, Konto-Menü im Kopf, Seite „Passwort neu setzen“. */ + +/* Knöpfe (eigene Klassen, damit das Konto-Modul nicht am Editor-Stylesheet hängt) */ + +.auth-btn { + appearance: none; + min-height: 44px; + margin: 0; + padding: 0 20px; + border: 1px solid var(--line-strong); + border-radius: 12px; + background: transparent; + color: var(--text); + font: inherit; + font-weight: 600; + cursor: pointer; + transition: background-color 0.12s, border-color 0.12s; +} + +.auth-btn:hover:not(:disabled) { + border-color: var(--sel-line); + background: var(--panel-2); +} + +.auth-btn-primary { + border-color: var(--primary); + background: var(--primary); + font-weight: 700; +} + +.auth-btn-primary:hover:not(:disabled) { + border-color: var(--primary-hover); + background: var(--primary-hover); +} + +.auth-btn-small { + min-height: 38px; + padding: 0 16px; +} + +.auth-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.auth-link { + appearance: none; + margin: 0; + padding: 0; + border: 0; + background: none; + color: var(--sel-line); + font: inherit; + text-decoration: underline; + cursor: pointer; +} + +.auth-link:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* Konto-Menü im Seitenkopf */ + +#account-slot { + min-width: 0; +} + +.account-menu { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + min-width: 0; + max-width: 100%; +} + +.account-row { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + max-width: 100%; +} + +.account-email { + min-width: 0; + max-width: 28ch; + overflow: hidden; + color: var(--muted); + font-size: 0.95rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-error { + color: #ff9a9d; + font-size: 0.85rem; + text-align: right; +} + +.account-error:empty { + display: none; +} + +/* Login-Dialog */ + +.auth-dialog { + width: min(420px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + padding: 0; + overflow: auto; + border: 1px solid var(--line-strong); + border-radius: 24px; + background: var(--panel); + color: var(--text); + box-shadow: 0 24px 80px rgb(0 0 0 / 0.55); +} + +.auth-dialog::backdrop { + background: rgb(6 11 20 / 0.72); +} + +.auth-card { + position: relative; + padding: 28px 28px 24px; +} + +.auth-close { + appearance: none; + position: absolute; + top: 12px; + right: 12px; + width: 40px; + height: 40px; + margin: 0; + padding: 0; + border: 0; + border-radius: 12px; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 1.6rem; + line-height: 1; + cursor: pointer; +} + +.auth-close:hover { + background: var(--panel-2); + color: var(--text); +} + +.auth-title { + margin: 0 40px 16px 0; + font-size: 1.4rem; + line-height: 1.2; +} + +.auth-tabs { + display: flex; + gap: 4px; + margin-bottom: 20px; + padding: 4px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--bg); +} + +.auth-tab { + appearance: none; + flex: 1; + min-height: 40px; + margin: 0; + padding: 0 12px; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--muted); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.auth-tab[aria-selected='true'] { + background: var(--sel-bg); + color: var(--text); +} + +.auth-tab:disabled { + cursor: not-allowed; +} + +/* Formulare (Dialog und Passwort-neu-Seite) */ + +.auth-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.auth-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.auth-label { + color: var(--muted); + font-size: 0.9rem; + font-weight: 600; +} + +.auth-input { + width: 100%; + min-height: 46px; + padding: 0 14px; + border: 1px solid var(--line-strong); + border-radius: 12px; + background: var(--bg); + color: var(--text); + font: inherit; + font-size: 1rem; /* mindestens 16 px, sonst zoomt iOS beim Fokussieren */ +} + +.auth-input:focus-visible { + outline: 2px solid var(--sel-line); + outline-offset: 1px; +} + +.auth-input[aria-invalid='true'] { + border-color: var(--danger); +} + +.auth-input[readonly] { + opacity: 0.7; +} + +.auth-hint { + margin: -6px 0 0; + color: var(--muted); + font-size: 0.85rem; +} + +.auth-msg { + margin: 0; + font-size: 0.95rem; +} + +.auth-msg:empty { + display: none; +} + +.auth-msg-error { + color: #ff9a9d; +} + +.auth-msg-ok { + color: #8fd9a8; +} + +.auth-aside { + margin: 0; + color: var(--muted); + font-size: 0.95rem; + text-align: center; +} + +/* Seite /passwort-neu */ + +.reset-page { + max-width: 420px; +} + +.reset-page .auth-msg { + margin-bottom: 16px; +} + +.reset-actions { + margin: 0; +} + +/* Schmale Bildschirme: Konto-Menü rutscht bei Bedarf in eine zweite Kopfzeile */ + +@media (max-width: 599px) { + #app-header { + flex-wrap: wrap; + gap: 8px 16px; + } + + #account-slot { + max-width: 100%; + } + + .account-email { + max-width: none; + } + + .auth-card { + padding: 24px 20px 20px; + } +} + +@media (prefers-reduced-motion: reduce) { + .auth-btn { + transition: none; + } +} diff --git a/web/src/account/appwrite.ts b/web/src/account/appwrite.ts new file mode 100644 index 0000000..7edc81a --- /dev/null +++ b/web/src/account/appwrite.ts @@ -0,0 +1,14 @@ +// Appwrite-Client der Seite: ein Client, die Dienste daraus. +// Im Browser läuft die API über dieselbe Domain (`/v1`), damit das Session-Cookie first-party ist. +import { Account, Client, Storage, TablesDB } from 'appwrite'; + +export const APPWRITE_ENDPOINT: string = import.meta.env.VITE_APPWRITE_ENDPOINT || `${location.origin}/v1`; +export const APPWRITE_PROJECT: string = import.meta.env.VITE_APPWRITE_PROJECT_ID || 'aircard'; + +/** Datenbank, Tabelle und Bucket des Projekts (siehe web/scripts/appwrite-setup.mjs). */ +export const IDS = { database: 'aircard', table: 'cards', bucket: 'card-images' } as const; + +export const client: Client = new Client().setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT); +export const account: Account = new Account(client); +export const tables: TablesDB = new TablesDB(client); +export const storage: Storage = new Storage(client); diff --git a/web/src/account/auth.test.ts b/web/src/account/auth.test.ts new file mode 100644 index 0000000..967e103 --- /dev/null +++ b/web/src/account/auth.test.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + get: vi.fn(), + create: vi.fn(), + createEmailPasswordSession: vi.fn(), + deleteSession: vi.fn(), + createRecovery: vi.fn(), + updateRecovery: vi.fn(), +})); + +vi.mock('./appwrite', () => ({ account: mocks })); + +type Auth = typeof import('./auth'); + +const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type }); + +// Der Nutzer-Cache lebt im Modul; jeder Test bekommt eine frische Instanz. +async function freshAuth(): Promise { + vi.resetModules(); + return import('./auth'); +} + +beforeEach(() => { + for (const fn of Object.values(mocks)) fn.mockReset(); +}); + +describe('getCurrentUser', () => { + it('liefert den Nutzer und merkt sich die ID', async () => { + const auth = await freshAuth(); + mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', name: 'a' }); + await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de' }); + expect(auth.currentUserId()).toBe('u1'); + }); + + it('401 heißt: nicht angemeldet', async () => { + const auth = await freshAuth(); + mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' }); + await auth.getCurrentUser(); + mocks.get.mockRejectedValueOnce(fail(401, 'general_unauthorized_scope')); + await expect(auth.getCurrentUser()).resolves.toBeNull(); + expect(auth.currentUserId()).toBeNull(); + }); + + it('currentUserId ist vor dem ersten Aufruf null', async () => { + const auth = await freshAuth(); + expect(auth.currentUserId()).toBeNull(); + }); + + it('eine Antwort ohne $id/email (z. B. HTML) ist ein Fehler und ändert den Cache nicht', async () => { + const auth = await freshAuth(); + mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' }); + await auth.getCurrentUser(); + mocks.get.mockResolvedValueOnce({ message: '' }); + await expect(auth.getCurrentUser()).rejects.toThrow('Unerwartete Antwort'); + expect(auth.currentUserId()).toBe('u1'); + }); + + it('andere Fehler werden weitergereicht', async () => { + const auth = await freshAuth(); + const error = new TypeError('Failed to fetch'); + mocks.get.mockRejectedValue(error); + await expect(auth.getCurrentUser()).rejects.toBe(error); + }); + + it('benachrichtigt nur, wenn sich der Nutzer ändert', async () => { + const auth = await freshAuth(); + const listener = vi.fn(); + auth.onAuthChange(listener); + mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' }); + await auth.getCurrentUser(); + await auth.getCurrentUser(); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de' }); + mocks.get.mockRejectedValue(fail(401, 'general_unauthorized_scope')); + await auth.getCurrentUser(); + await auth.getCurrentUser(); + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenLastCalledWith(null); + }); +}); + +describe('login', () => { + it('legt die Sitzung an, holt den Nutzer und benachrichtigt', async () => { + const auth = await freshAuth(); + const listener = vi.fn(); + auth.onAuthChange(listener); + mocks.createEmailPasswordSession.mockResolvedValue({}); + mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' }); + await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' }); + expect(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'a@b.de', password: 'geheim123' }); + expect(auth.currentUserId()).toBe('u1'); + expect(listener).toHaveBeenCalledWith({ $id: 'u1', email: 'a@b.de' }); + }); + + it('bei falschem Passwort fliegt der Fehler weiter und nichts ändert sich', async () => { + const auth = await freshAuth(); + const listener = vi.fn(); + auth.onAuthChange(listener); + const error = fail(401, 'user_invalid_credentials'); + mocks.createEmailPasswordSession.mockRejectedValue(error); + await expect(auth.login('a@b.de', 'falsch123')).rejects.toBe(error); + expect(mocks.get).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); + expect(auth.currentUserId()).toBeNull(); + }); +}); + +describe('register', () => { + it('legt das Konto an (Name aus der E-Mail) und meldet danach an', async () => { + const auth = await freshAuth(); + mocks.create.mockResolvedValue({}); + mocks.createEmailPasswordSession.mockResolvedValue({}); + mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' }); + await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de' }); + const params = mocks.create.mock.calls[0][0]; + expect(params).toMatchObject({ email: 'neu@b.de', password: 'geheim123', name: 'neu' }); + expect(typeof params.userId).toBe('string'); + expect(params.userId.length).toBeGreaterThan(0); + expect(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'neu@b.de', password: 'geheim123' }); + expect(auth.currentUserId()).toBe('u2'); + }); + + it('kürzt den Namen auf 128 Zeichen', async () => { + const auth = await freshAuth(); + mocks.create.mockResolvedValue({}); + mocks.createEmailPasswordSession.mockResolvedValue({}); + mocks.get.mockResolvedValue({ $id: 'u2', email: 'x@b.de' }); + await auth.register(`${'a'.repeat(200)}@b.de`, 'geheim123'); + expect(mocks.create.mock.calls[0][0].name).toBe('a'.repeat(128)); + }); + + it('meldet nicht an, wenn das Konto nicht angelegt werden konnte', async () => { + const auth = await freshAuth(); + const error = fail(409, 'user_already_exists'); + mocks.create.mockRejectedValue(error); + await expect(auth.register('a@b.de', 'geheim123')).rejects.toBe(error); + expect(mocks.createEmailPasswordSession).not.toHaveBeenCalled(); + }); +}); + +describe('logout', () => { + it('löscht die aktuelle Sitzung, leert den Cache und benachrichtigt', async () => { + const auth = await freshAuth(); + mocks.createEmailPasswordSession.mockResolvedValue({}); + mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' }); + await auth.login('a@b.de', 'geheim123'); + const listener = vi.fn(); + auth.onAuthChange(listener); + mocks.deleteSession.mockResolvedValue({}); + await auth.logout(); + expect(mocks.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' }); + expect(auth.currentUserId()).toBeNull(); + expect(listener).toHaveBeenCalledWith(null); + }); + + it('401 (Sitzung war schon weg) löst auf und ruft onAuthChange(null)', async () => { + const auth = await freshAuth(); + const listener = vi.fn(); + auth.onAuthChange(listener); + mocks.deleteSession.mockRejectedValue(fail(401, 'general_unauthorized_scope')); + await expect(auth.logout()).resolves.toBeUndefined(); + expect(listener).toHaveBeenCalledWith(null); + expect(auth.currentUserId()).toBeNull(); + }); + + it('andere Fehler (z. B. Netz) fliegen weiter, der Cache bleibt', async () => { + const auth = await freshAuth(); + mocks.createEmailPasswordSession.mockResolvedValue({}); + mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' }); + await auth.login('a@b.de', 'geheim123'); + mocks.deleteSession.mockRejectedValue(new TypeError('Failed to fetch')); + await expect(auth.logout()).rejects.toBeInstanceOf(TypeError); + expect(auth.currentUserId()).toBe('u1'); + }); +}); + +describe('onAuthChange', () => { + it('gibt eine Abmelde-Funktion zurück', async () => { + const auth = await freshAuth(); + const listener = vi.fn(); + const off = auth.onAuthChange(listener); + off(); + mocks.deleteSession.mockResolvedValue({}); + await auth.logout(); + expect(listener).not.toHaveBeenCalled(); + }); + + it('ein Abonnent darf sich beim Benachrichtigen abmelden', async () => { + const auth = await freshAuth(); + const second = vi.fn(); + const off = auth.onAuthChange(() => off()); + auth.onAuthChange(second); + mocks.deleteSession.mockResolvedValue({}); + await auth.logout(); + expect(second).toHaveBeenCalledTimes(1); + }); +}); + +describe('Passwort zurücksetzen', () => { + it('requestPasswordReset sendet die Adresse und die Rücksprung-URL', async () => { + const auth = await freshAuth(); + mocks.createRecovery.mockResolvedValue({}); + await auth.requestPasswordReset('a@b.de'); + expect(mocks.createRecovery).toHaveBeenCalledWith({ email: 'a@b.de', url: `${location.origin}/passwort-neu` }); + }); + + it('completePasswordReset sendet userId, secret und neues Passwort', async () => { + const auth = await freshAuth(); + mocks.updateRecovery.mockResolvedValue({}); + await auth.completePasswordReset('u1', 'geheim-token', 'neuesPasswort1'); + expect(mocks.updateRecovery).toHaveBeenCalledWith({ userId: 'u1', secret: 'geheim-token', password: 'neuesPasswort1' }); + }); +}); + +describe('validatePassword', () => { + it('lehnt zu kurze Passwörter mit einer Meldung ab', async () => { + const auth = await freshAuth(); + expect(auth.validatePassword('kurz')).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.'); + expect(auth.validatePassword('')).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.'); + expect(auth.validatePassword('1234567')).not.toBeNull(); + }); + + it('nimmt 8 bis 256 Zeichen an', async () => { + const auth = await freshAuth(); + expect(auth.validatePassword('12345678')).toBeNull(); + expect(auth.validatePassword('a'.repeat(256))).toBeNull(); + }); + + it('lehnt mehr als 256 Zeichen ab', async () => { + const auth = await freshAuth(); + expect(auth.validatePassword('a'.repeat(257))).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.'); + }); +}); diff --git a/web/src/account/auth.ts b/web/src/account/auth.ts new file mode 100644 index 0000000..187f41f --- /dev/null +++ b/web/src/account/auth.ts @@ -0,0 +1,103 @@ +// Anmelden, Registrieren, Abmelden, Passwort zurücksetzen. Hält den angemeldeten Nutzer im Speicher +// und meldet Änderungen an Abonnenten (Konto-Menü, später Karten-Seite). +import { ID } from 'appwrite'; +import { account } from './appwrite'; +import { isUnauthorized } from './errors'; + +export type User = { $id: string; email: string }; + +const PASSWORD_MIN = 8; +const PASSWORD_MAX = 256; + +let cachedUser: User | null = null; +const listeners = new Set<(user: User | null) => void>(); + +function notify(): void { + // Kopie, damit sich ein Abonnent beim Verteilen abmelden darf. + for (const fn of [...listeners]) fn(cachedUser); +} + +function setUser(user: User | null): void { + cachedUser = user; + notify(); +} + +/** Nur die Felder, die die Seite braucht; alles andere (z. B. eine HTML-Fehlerseite) ist ein Fehler. */ +function toUser(res: unknown): User { + const data = (typeof res === 'object' && res !== null ? res : {}) as Record; + if (typeof data.$id !== 'string' || typeof data.email !== 'string') throw new Error('Unerwartete Antwort'); + return { $id: data.$id, email: data.email }; +} + +/** Der angemeldete Nutzer laut Server; 401 heißt „nicht angemeldet“. Abonnenten hören nur echte Änderungen. */ +export async function getCurrentUser(): Promise { + let next: User | null; + try { + next = toUser(await account.get()); + } catch (err) { + if (!isUnauthorized(err)) throw err; + next = null; + } + if (next?.$id !== cachedUser?.$id || next?.email !== cachedUser?.email) setUser(next); + return next; +} + +/** Der zuletzt bekannte Nutzer, ohne Netzwerkzugriff. */ +export function currentUser(): User | null { + return cachedUser; +} + +/** Die ID des zuletzt bekannten Nutzers (aus getCurrentUser, login, register, logout). */ +export function currentUserId(): string | null { + return cachedUser?.$id ?? null; +} + +export async function login(email: string, password: string): Promise { + await account.createEmailPasswordSession({ email, password }); + const user = toUser(await account.get()); + setUser(user); + return user; +} + +export async function register(email: string, password: string): Promise { + await account.create({ + userId: ID.unique(), + email, + password, + name: email.split('@')[0].slice(0, 128), + }); + return login(email, password); +} + +export async function logout(): Promise { + try { + await account.deleteSession({ sessionId: 'current' }); + } catch (err) { + // 401: die Sitzung gab es schon nicht mehr – das Ziel „abgemeldet“ ist erreicht. + if (!isUnauthorized(err)) throw err; + } + setUser(null); +} + +export async function requestPasswordReset(email: string): Promise { + await account.createRecovery({ email, url: `${location.origin}/passwort-neu` }); +} + +export async function completePasswordReset(userId: string, secret: string, password: string): Promise { + await account.updateRecovery({ userId, secret, password }); +} + +/** Registriert eine Funktion, die bei jeder Änderung des Nutzers aufgerufen wird; gibt die Abmelde-Funktion zurück. */ +export function onAuthChange(fn: (user: User | null) => void): () => void { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; +} + +/** null, wenn das Passwort passt; sonst die Meldung für den Nutzer. */ +export function validatePassword(pw: string): string | null { + return pw.length >= PASSWORD_MIN && pw.length <= PASSWORD_MAX + ? null + : `Das Passwort muss ${PASSWORD_MIN} bis ${PASSWORD_MAX} Zeichen lang sein.`; +} diff --git a/web/src/account/errors.test.ts b/web/src/account/errors.test.ts new file mode 100644 index 0000000..0ba90f5 --- /dev/null +++ b/web/src/account/errors.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; +import { errorMessage, errorType, isInvalidLinkError, isUnauthorized, type ErrorContext } from './errors'; + +/** Fehler wie vom SDK (AppwriteException): Felder code, type, message. */ +const fail = (code: number, type: string, message = '') => ({ code, type, message }); + +const LINK = 'Der Link ist ungültig oder abgelaufen.'; +const WRONG = 'E-Mail oder Passwort ist falsch.'; +const LENGTH = 'Das Passwort muss 8 bis 256 Zeichen lang sein.'; +const COMMON = 'Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.'; +const EMAIL = 'Bitte gib eine gültige E-Mail-Adresse ein.'; +const GENERIC = 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.'; + +const PW_LENGTH = 'Invalid `password` param: Password must be between 8 and 256 characters long.'; +const PW_COMMON = + 'Invalid `password` param: Password must be at least 8 characters and should not be one of the commonly used password.'; +const EMAIL_INVALID = 'Invalid `email` param: Value must be a valid email address'; + +describe('errorMessage nach type', () => { + it('user_invalid_credentials', () => { + expect(errorMessage(fail(401, 'user_invalid_credentials', 'Invalid credentials.'), 'login')).toBe(WRONG); + }); + + it('user_already_exists', () => { + expect(errorMessage(fail(409, 'user_already_exists'), 'register')).toBe( + 'Diese E-Mail ist schon registriert. Melde dich an.', + ); + }); + + it('user_invalid_token', () => { + expect(errorMessage(fail(401, 'user_invalid_token'), 'reset')).toBe(LINK); + }); + + it('user_not_found hängt vom Kontext ab', () => { + expect(errorMessage(fail(404, 'user_not_found'), 'recover')).toBe('Zu dieser E-Mail gibt es kein Konto.'); + expect(errorMessage(fail(404, 'user_not_found'), 'reset')).toBe(LINK); + }); + + it('user_not_found in anderen Kontexten fällt auf die allgemeine Meldung zurück', () => { + expect(errorMessage(fail(404, 'user_not_found'), 'save')).toBe(GENERIC); + }); + + it('row_not_found', () => { + expect(errorMessage(fail(404, 'row_not_found'), 'load')).toBe( + 'Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.', + ); + }); + + it('storage_file_not_found', () => { + expect(errorMessage(fail(404, 'storage_file_not_found'), 'load')).toBe('Das Bild dieser Karte fehlt.'); + }); + + it('general_unknown_origin', () => { + expect(errorMessage(fail(403, 'general_unknown_origin'), 'login')).toBe( + 'Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.', + ); + }); + + it('user_count_exceeded', () => { + expect(errorMessage(fail(501, 'user_count_exceeded'), 'register')).toBe('Zurzeit sind keine neuen Konten möglich.'); + }); +}); + +describe('errorMessage bei general_argument_invalid', () => { + const invalid = (message: string) => fail(400, 'general_argument_invalid', message); + + it('email', () => { + for (const ctx of ['login', 'register', 'recover'] as ErrorContext[]) { + expect(errorMessage(invalid(EMAIL_INVALID), ctx)).toBe(EMAIL); + } + }); + + it('password beim Login heißt: falsche Zugangsdaten', () => { + expect(errorMessage(invalid(PW_LENGTH), 'login')).toBe(WRONG); + }); + + it('password sonst: 8 bis 256 Zeichen', () => { + expect(errorMessage(invalid(PW_LENGTH), 'register')).toBe(LENGTH); + expect(errorMessage(invalid(PW_LENGTH), 'reset')).toBe(LENGTH); + }); + + it('password sonst: zu verbreitet', () => { + expect(errorMessage(invalid(PW_COMMON), 'register')).toBe(COMMON); + expect(errorMessage(invalid(PW_COMMON), 'reset')).toBe(COMMON); + }); + + it('userId und secret: Link ungültig', () => { + expect(errorMessage(invalid('Invalid `userId` param: Parameter must be a valid number'), 'reset')).toBe(LINK); + expect(errorMessage(invalid('Invalid `secret` param: Value must be a valid string'), 'reset')).toBe(LINK); + }); + + it('unbekannter oder fehlender Parameter: allgemeine Meldung', () => { + expect(errorMessage(invalid('Invalid `url` param: Invalid URI'), 'recover')).toBe(GENERIC); + expect(errorMessage(invalid('Something is wrong'), 'register')).toBe(GENERIC); + }); +}); + +describe('errorMessage nach code und Fehlerart', () => { + it('429', () => { + expect(errorMessage(fail(429, 'general_rate_limit_exceeded'), 'login')).toBe('Zu viele Versuche – bitte kurz warten.'); + expect(errorMessage(fail(429, ''), 'recover')).toBe('Zu viele Versuche – bitte kurz warten.'); + }); + + it('type geht vor code', () => { + expect(errorMessage(fail(429, 'user_invalid_credentials'), 'login')).toBe(WRONG); + }); + + it('TypeError ist ein Netzfehler', () => { + expect(errorMessage(new TypeError('Failed to fetch'), 'save')).toBe('Keine Verbindung zum Server.'); + }); + + it('alles andere ist die allgemeine Meldung', () => { + expect(errorMessage(fail(500, 'general_server_error', 'boom'), 'save')).toBe(GENERIC); + expect(errorMessage(new Error('x'), 'load')).toBe(GENERIC); + expect(errorMessage(undefined, 'load')).toBe(GENERIC); + expect(errorMessage('text', 'load')).toBe(GENERIC); + expect(errorMessage(null, 'load')).toBe(GENERIC); + }); +}); + +describe('isUnauthorized', () => { + it('erkennt Code 401', () => { + expect(isUnauthorized({ code: 401 })).toBe(true); + }); + + it('andere Codes und Nicht-Fehler nicht', () => { + expect(isUnauthorized({ code: 404 })).toBe(false); + expect(isUnauthorized(new TypeError('Failed to fetch'))).toBe(false); + expect(isUnauthorized(null)).toBe(false); + expect(isUnauthorized(undefined)).toBe(false); + }); +}); + +describe('errorType', () => { + it('liefert den type-String oder null', () => { + expect(errorType(fail(409, 'user_already_exists'))).toBe('user_already_exists'); + expect(errorType(fail(500, ''))).toBeNull(); + expect(errorType(new TypeError('x'))).toBeNull(); + expect(errorType(undefined)).toBeNull(); + }); +}); + +describe('isInvalidLinkError (Passwort-neu-Seite)', () => { + it('Link-Fehler', () => { + expect(isInvalidLinkError(fail(401, 'user_invalid_token'))).toBe(true); + expect(isInvalidLinkError(fail(404, 'user_not_found'))).toBe(true); + expect(isInvalidLinkError(fail(400, 'general_argument_invalid', 'Invalid `userId` param: x'))).toBe(true); + expect(isInvalidLinkError(fail(400, 'general_argument_invalid', 'Invalid `secret` param: x'))).toBe(true); + }); + + it('Passwort- und sonstige Fehler gehören zum Feld', () => { + expect(isInvalidLinkError(fail(400, 'general_argument_invalid', PW_LENGTH))).toBe(false); + expect(isInvalidLinkError(fail(429, 'general_rate_limit_exceeded'))).toBe(false); + expect(isInvalidLinkError(new TypeError('Failed to fetch'))).toBe(false); + expect(isInvalidLinkError(undefined)).toBe(false); + }); +}); diff --git a/web/src/account/errors.ts b/web/src/account/errors.ts new file mode 100644 index 0000000..54f5d6e --- /dev/null +++ b/web/src/account/errors.ts @@ -0,0 +1,83 @@ +// Deutsche Fehlermeldungen für Appwrite-Fehler. Rein und ohne Appwrite-Import, damit gut testbar. +// Ein AppwriteException trägt `code` (HTTP-Status), `type` (z. B. user_invalid_credentials) und `message`. + +export type ErrorContext = 'login' | 'register' | 'recover' | 'reset' | 'save' | 'load'; + +const LINK_INVALID = 'Der Link ist ungültig oder abgelaufen.'; +const WRONG_CREDENTIALS = 'E-Mail oder Passwort ist falsch.'; +const GENERIC = 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.'; + +function field(err: unknown, key: 'code' | 'type' | 'message'): unknown { + return typeof err === 'object' && err !== null ? (err as Record)[key] : undefined; +} + +/** Der Fehlertyp des Servers (`type`), sonst null. */ +export function errorType(err: unknown): string | null { + const type = field(err, 'type'); + return typeof type === 'string' && type !== '' ? type : null; +} + +/** Der Server hat die Sitzung abgelehnt (HTTP 401). */ +export function isUnauthorized(err: unknown): boolean { + return field(err, 'code') === 401; +} + +/** Bei `general_argument_invalid` nennt die Nachricht den Parameter: „Invalid `email` param: …“. */ +function invalidParam(err: unknown): string | null { + const message = field(err, 'message'); + return typeof message === 'string' ? (/`(\w+)` param/.exec(message)?.[1] ?? null) : null; +} + +/** Passwort-neu-Seite: Fehler, die bedeuten, dass der Link aus der Mail nicht (mehr) taugt. */ +export function isInvalidLinkError(err: unknown): boolean { + const type = errorType(err); + if (type === 'user_invalid_token' || type === 'user_not_found') return true; + if (type === 'general_argument_invalid') { + const param = invalidParam(err); + return param === 'userId' || param === 'secret'; + } + return false; +} + +function passwordMessage(err: unknown, ctx: ErrorContext): string { + if (ctx === 'login') return WRONG_CREDENTIALS; + const message = field(err, 'message'); + if (typeof message === 'string' && message.includes('commonly used')) { + return 'Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.'; + } + return 'Das Passwort muss 8 bis 256 Zeichen lang sein.'; +} + +/** Meldung für Nutzer: zuerst nach `type`, dann nach `code`, Netzfehler (TypeError) zuletzt. */ +export function errorMessage(err: unknown, ctx: ErrorContext): string { + switch (errorType(err)) { + case 'user_invalid_credentials': + return WRONG_CREDENTIALS; + case 'user_already_exists': + return 'Diese E-Mail ist schon registriert. Melde dich an.'; + case 'user_invalid_token': + return LINK_INVALID; + case 'user_not_found': + if (ctx === 'recover') return 'Zu dieser E-Mail gibt es kein Konto.'; + if (ctx === 'reset') return LINK_INVALID; + break; + case 'general_argument_invalid': { + const param = invalidParam(err); + if (param === 'email') return 'Bitte gib eine gültige E-Mail-Adresse ein.'; + if (param === 'password') return passwordMessage(err, ctx); + if (param === 'userId' || param === 'secret') return LINK_INVALID; + break; + } + case 'row_not_found': + return 'Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.'; + case 'storage_file_not_found': + return 'Das Bild dieser Karte fehlt.'; + case 'general_unknown_origin': + return 'Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.'; + case 'user_count_exceeded': + return 'Zurzeit sind keine neuen Konten möglich.'; + } + if (field(err, 'code') === 429) return 'Zu viele Versuche – bitte kurz warten.'; + if (err instanceof TypeError) return 'Keine Verbindung zum Server.'; + return GENERIC; +} diff --git a/web/src/account/login-dialog.test.ts b/web/src/account/login-dialog.test.ts new file mode 100644 index 0000000..fe050d2 --- /dev/null +++ b/web/src/account/login-dialog.test.ts @@ -0,0 +1,252 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + get: vi.fn(), + create: vi.fn(), + createEmailPasswordSession: vi.fn(), + deleteSession: vi.fn(), + createRecovery: vi.fn(), + updateRecovery: vi.fn(), +})); + +vi.mock('./appwrite', () => ({ account: mocks })); + +const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type }); + +type Dialog = typeof import('./login-dialog'); +let requireLogin: Dialog['requireLogin']; +let opener: HTMLButtonElement; + +beforeEach(async () => { + for (const fn of Object.values(mocks)) fn.mockReset(); + vi.resetModules(); + document.body.innerHTML = '
'; + opener = document.getElementById('opener') as HTMLButtonElement; + opener.focus(); + ({ requireLogin } = await import('./login-dialog')); +}); + +const dialogEl = () => document.querySelector('dialog.auth-dialog')!; +const title = () => dialogEl().querySelector('h2')!.textContent; +const input = (name: string) => dialogEl().querySelector(`input[name="${name}"]`)!; +const alertText = () => dialogEl().querySelector('[role="alert"]')!.textContent; +const buttonByText = (text: string) => + [...dialogEl().querySelectorAll('button')].find((b) => b.textContent === text) as HTMLButtonElement; +const submit = () => + dialogEl().querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); +const fill = (values: Record) => { + for (const [name, value] of Object.entries(values)) input(name).value = value; +}; + +describe('requireLogin: Öffnen und Schließen', () => { + it('öffnet den Dialog mit Anmelden und fokussiert die E-Mail', () => { + void requireLogin(); + expect(dialogEl().open).toBe(true); + expect(title()).toBe('Anmelden'); + expect(document.activeElement).toBe(input('email')); + expect(input('email').autocomplete).toBe('email'); + expect(input('password').autocomplete).toBe('current-password'); + }); + + it('startet auf Wunsch mit Registrieren oder Passwort vergessen', () => { + void requireLogin('register'); + expect(title()).toBe('Registrieren'); + expect(input('password').autocomplete).toBe('new-password'); + expect(input('password-repeat').autocomplete).toBe('new-password'); + }); + + it('der Schließen-Knopf löst mit false auf und gibt den Fokus zurück', async () => { + const result = requireLogin(); + dialogEl().querySelector('.auth-close')!.click(); + await expect(result).resolves.toBe(false); + expect(dialogEl().open).toBe(false); + expect(document.activeElement).toBe(opener); + }); + + it('Esc (close-Ereignis des Dialogs) löst mit false auf', async () => { + const result = requireLogin(); + dialogEl().close(); + await expect(result).resolves.toBe(false); + }); + + it('ein Klick auf den Hintergrund schließt, ein Klick im Formular nicht', async () => { + const result = requireLogin(); + const email = input('email'); + email.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + email.click(); + expect(dialogEl().open).toBe(true); + dialogEl().dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + dialogEl().click(); + await expect(result).resolves.toBe(false); + expect(dialogEl().open).toBe(false); + }); + + it('Markieren, das außerhalb endet (mousedown im Feld, click auf dem Dialog), schließt nicht', () => { + void requireLogin(); + input('email').dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + dialogEl().click(); + expect(dialogEl().open).toBe(true); + }); + + it('gleichzeitige Aufrufe teilen sich dasselbe Ergebnis und einen Dialog', () => { + const first = requireLogin(); + const second = requireLogin('register'); + expect(second).toBe(first); + expect(document.querySelectorAll('dialog.auth-dialog')).toHaveLength(1); + expect(title()).toBe('Anmelden'); + }); + + it('nach dem Schließen öffnet der nächste Aufruf neu, ohne Reste', async () => { + const first = requireLogin(); + fill({ email: 'a@b.de', password: 'geheim123' }); + dialogEl().close(); + await first; + const second = requireLogin(); + expect(second).not.toBe(first); + expect(dialogEl().open).toBe(true); + expect(input('email').value).toBe(''); + expect(input('password').value).toBe(''); + }); +}); + +describe('Anmelden', () => { + it('meldet an, löst mit true auf und schließt', 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(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'a@b.de', password: 'geheim123' }); + expect(dialogEl().open).toBe(false); + }); + + it('zeigt bei falschem Passwort die Meldung, bleibt offen und gibt die Knöpfe wieder frei', async () => { + mocks.createEmailPasswordSession.mockRejectedValue(fail(401, 'user_invalid_credentials')); + let settled = false; + void requireLogin().then(() => (settled = true)); + fill({ email: 'a@b.de', password: 'falsch123' }); + submit(); + await vi.waitFor(() => expect(alertText()).toBe('E-Mail oder Passwort ist falsch.')); + expect(dialogEl().open).toBe(true); + expect(settled).toBe(false); + expect(buttonByText('Passwort vergessen?').disabled).toBe(false); + expect(dialogEl().querySelector('button[type="submit"]')!.disabled).toBe(false); + expect(input('email').readOnly).toBe(false); + }); + + it('sperrt Knöpfe und ignoriert ein zweites Absenden, solange die Anfrage läuft', async () => { + let release!: () => void; + mocks.createEmailPasswordSession.mockReturnValue(new Promise((resolve) => (release = resolve))); + mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' }); + const result = requireLogin(); + fill({ email: 'a@b.de', password: 'geheim123' }); + submit(); + submit(); + expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(1); + expect(dialogEl().querySelector('button[type="submit"]')!.disabled).toBe(true); + expect(buttonByText('Registrieren').disabled).toBe(true); + release(); + await expect(result).resolves.toBe(true); + }); + + it('Netzfehler: Meldung statt Absturz', async () => { + mocks.createEmailPasswordSession.mockRejectedValue(new TypeError('Failed to fetch')); + void requireLogin(); + fill({ email: 'a@b.de', password: 'geheim123' }); + submit(); + await vi.waitFor(() => expect(alertText()).toBe('Keine Verbindung zum Server.')); + }); + + it('eine Antwort nach dem Schließen ändert nichts mehr', async () => { + let release!: () => void; + mocks.createEmailPasswordSession.mockReturnValue(new Promise((resolve) => (release = resolve))); + mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' }); + const result = requireLogin(); + fill({ email: 'a@b.de', password: 'geheim123' }); + submit(); + dialogEl().querySelector('.auth-close')!.click(); + await expect(result).resolves.toBe(false); + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(dialogEl().open).toBe(false); + }); +}); + +describe('Registrieren', () => { + it('der Reiter wechselt die Ansicht und behält die E-Mail', () => { + void requireLogin(); + fill({ email: 'a@b.de' }); + buttonByText('Registrieren').click(); + expect(title()).toBe('Registrieren'); + expect(input('email').value).toBe('a@b.de'); + expect(dialogEl().querySelector('#auth-tab-register')!.getAttribute('aria-selected')).toBe('true'); + expect(dialogEl().querySelector('#auth-tab-login')!.getAttribute('aria-selected')).toBe('false'); + }); + + it('prüft vor dem Senden: Abgleich, dann Länge', () => { + void requireLogin('register'); + fill({ email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim124' }); + submit(); + expect(alertText()).toBe('Die Passwörter stimmen nicht überein.'); + fill({ password: 'kurz', 'password-repeat': 'kurz' }); + submit(); + expect(alertText()).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.'); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it('legt das Konto an, meldet an und löst mit true auf', async () => { + mocks.create.mockResolvedValue({}); + mocks.createEmailPasswordSession.mockResolvedValue({}); + mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' }); + const result = requireLogin('register'); + fill({ email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' }); + submit(); + await expect(result).resolves.toBe(true); + expect(mocks.create).toHaveBeenCalledTimes(1); + expect(dialogEl().open).toBe(false); + }); + + it('bei vorhandener E-Mail steht die Meldung mit einem Link „Anmelden“', async () => { + mocks.create.mockRejectedValue(fail(409, 'user_already_exists')); + void requireLogin('register'); + fill({ email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim123' }); + submit(); + await vi.waitFor(() => expect(alertText()).toContain('Diese E-Mail ist schon registriert. Melde dich an.')); + const link = dialogEl().querySelector('[role="alert"] button')!; + expect(link.textContent).toBe('Anmelden'); + link.click(); + expect(title()).toBe('Anmelden'); + expect(input('email').value).toBe('a@b.de'); + }); +}); + +describe('Passwort vergessen', () => { + it('führt vom Anmelden zur E-Mail-Eingabe und sendet den Link', async () => { + mocks.createRecovery.mockResolvedValue({}); + void requireLogin(); + fill({ email: 'a@b.de' }); + buttonByText('Passwort vergessen?').click(); + expect(title()).toBe('Passwort vergessen'); + expect(input('email').value).toBe('a@b.de'); + expect(dialogEl().querySelector('input[type="password"]')).toBeNull(); + submit(); + await vi.waitFor(() => + expect(dialogEl().querySelector('[role="status"]')!.textContent).toBe( + 'Wir haben dir einen Link geschickt. Er gilt eine Stunde.', + ), + ); + expect(mocks.createRecovery).toHaveBeenCalledWith({ email: 'a@b.de', url: `${location.origin}/passwort-neu` }); + expect(dialogEl().open).toBe(true); + }); + + it('zeigt „kein Konto“ als Fehler und führt zurück zur Anmeldung', async () => { + mocks.createRecovery.mockRejectedValue(fail(404, 'user_not_found')); + void requireLogin('forgot'); + fill({ email: 'x@b.de' }); + submit(); + await vi.waitFor(() => expect(alertText()).toBe('Zu dieser E-Mail gibt es kein Konto.')); + buttonByText('Zurück zur Anmeldung').click(); + expect(title()).toBe('Anmelden'); + }); +}); diff --git a/web/src/account/login-dialog.ts b/web/src/account/login-dialog.ts new file mode 100644 index 0000000..b58827c --- /dev/null +++ b/web/src/account/login-dialog.ts @@ -0,0 +1,308 @@ +// Login-Dialog: ein gemeinsames mit Anmelden, Registrieren und „Passwort vergessen“. +import './account.css'; +import { el } from '../editor/dom'; +import { login, register, requestPasswordReset, validatePassword } from './auth'; +import { errorMessage, errorType, type ErrorContext } from './errors'; + +export type LoginView = 'login' | 'register' | 'forgot'; + +const TITLES: Record = { + login: 'Anmelden', + register: 'Registrieren', + forgot: 'Passwort vergessen', +}; + +const MISMATCH = 'Die Passwörter stimmen nicht überein.'; +const RESET_SENT = 'Wir haben dir einen Link geschickt. Er gilt eine Stunde.'; + +type Field = { label: HTMLLabelElement; input: HTMLInputElement }; + +/** Alles, was eine Ansicht dem Rahmen übergibt: Formular, erstes Feld, Meldungen. */ +type Panel = { + form: HTMLFormElement; + first: HTMLElement; + errorEl: HTMLElement; + okEl: HTMLElement; +}; + +function createLoginDialog(): (view: LoginView) => Promise { + let view: LoginView = 'login'; + let draftEmail = ''; + let busy = false; + // Zählt jedes Öffnen/Schließen; eine Antwort, die nach dem Schließen eintrifft, wird verworfen. + let session = 0; + let opener: HTMLElement | null = null; + let settle: ((ok: boolean) => void) | null = null; + let pending: Promise | null = null; + + const closeButton = el( + 'button', + { type: 'button', class: 'auth-close', 'aria-label': 'Schließen' }, + el('span', { 'aria-hidden': 'true' }, '×'), + ); + const title = el('h2', { id: 'auth-title', class: 'auth-title' }); + const content = el('div', { class: 'auth-content' }); + const dialog = el( + 'dialog', + { class: 'auth-dialog', 'aria-labelledby': 'auth-title' }, + el('div', { class: 'auth-card' }, closeButton, title, content), + ); + document.body.append(dialog); + + /** Beendet den Dialog genau einmal; Esc, Schließen-Knopf und Hintergrund laufen hier zusammen. */ + function finish(ok: boolean): void { + const done = settle; + if (!done) return; + settle = null; + pending = null; + busy = false; + session += 1; + content.replaceChildren(); // keine Eingaben (Passwörter) im geschlossenen Dialog zurücklassen + if (dialog.open) dialog.close(); + restoreFocus(); + done(ok); + } + + function restoreFocus(): void { + // Der „Anmelden“-Knopf im Konto-Menü wird nach dem Login ersetzt; dann geht der Fokus an dessen Nachfolger. + const target = opener?.isConnected ? opener : document.querySelector('#account-slot button'); + opener = null; + target?.focus(); + } + + closeButton.addEventListener('click', () => finish(false)); + // Esc schließt nativ; `close` kommt asynchron, daher nicht eingreifen, wenn der Dialog schon wieder offen ist. + dialog.addEventListener('close', () => { + if (!dialog.open) finish(false); + }); + // Hintergrund = Klick auf das selbst (die Karte füllt es ganz aus); Drücken und Loslassen müssen beide dort sein, + // sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog. + let pressedOnBackdrop = false; + dialog.addEventListener('mousedown', (event) => { + pressedOnBackdrop = event.target === dialog; + }); + dialog.addEventListener('click', (event) => { + if (event.target === dialog && pressedOnBackdrop) finish(false); + pressedOnBackdrop = false; + }); + + function field( + labelText: string, + name: string, + type: 'email' | 'password', + autocomplete: string, + value = '', + describedBy?: string, + ): Field { + const input = el('input', { + class: 'auth-input', + type, + name, + autocomplete, + value, + required: true, + autocapitalize: type === 'email' ? 'none' : null, + spellcheck: type === 'email' ? 'false' : null, + 'aria-describedby': describedBy, + }); + const label = el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, labelText), input); + return { label, input }; + } + + function linkButton(text: string, onClick: () => void): HTMLButtonElement { + const button = el('button', { type: 'button', class: 'auth-link' }, text); + button.addEventListener('click', () => { + if (!busy) onClick(); + }); + return button; + } + + function buildTabs(): HTMLElement { + const select = (next: LoginView) => { + if (busy || next === view) return; + showView(next, 'tab'); + }; + const tabs = (['login', 'register'] as const).map((tabView) => { + const selected = view === tabView; + const tab = el( + 'button', + { + type: 'button', + role: 'tab', + id: `auth-tab-${tabView}`, + class: 'auth-tab', + 'aria-selected': String(selected), + 'aria-controls': 'auth-panel', + tabindex: selected ? 0 : -1, + }, + TITLES[tabView], + ); + tab.addEventListener('click', () => select(tabView)); + tab.addEventListener('keydown', (event) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + select(tabView === 'login' ? 'register' : 'login'); + }); + return tab; + }); + return el('div', { class: 'auth-tabs', role: 'tablist', 'aria-label': 'Konto' }, ...tabs); + } + + /** Setzt oder sperrt alle Bedienelemente, solange eine Anfrage läuft; Eingabefelder bleiben fokussierbar. */ + function setBusy(panel: Panel, value: boolean): void { + busy = value; + panel.form.setAttribute('aria-busy', String(value)); + for (const input of panel.form.querySelectorAll('input')) input.readOnly = value; + for (const button of content.querySelectorAll('button')) button.disabled = value; + } + + function showError(panel: Panel, text: string, extra?: Node): void { + panel.okEl.textContent = ''; + panel.errorEl.replaceChildren(text, ...(extra ? [' ', extra] : [])); + } + + function showOk(panel: Panel, text: string): void { + panel.errorEl.textContent = ''; + panel.okEl.textContent = text; + } + + /** Führt eine Anfrage aus: sperrt das Formular, zeigt Fehler und gibt danach den Fokus zurück. */ + async function run( + panel: Panel, + ctx: ErrorContext, + action: () => Promise, + onError?: (err: unknown) => Node | undefined, + ): Promise { + const mine = session; + const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + setBusy(panel, true); + panel.errorEl.textContent = ''; + panel.okEl.textContent = ''; + try { + await action(); + } catch (err) { + if (mine !== session) return; + showError(panel, errorMessage(err, ctx), onError?.(err)); + } + if (mine !== session) return; // Dialog wurde inzwischen geschlossen (auch nach Erfolg) + setBusy(panel, false); + if (focused?.isConnected && dialog.contains(focused)) focused.focus(); + } + + function buildPanel(forView: LoginView): Panel { + const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' }); + const okEl = el('p', { class: 'auth-msg auth-msg-ok', role: 'status' }); + const form = el('form', { class: 'auth-form' }); + const submit = (text: string) => el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, text); + + if (forView === 'login') { + const email = field('E-Mail', 'email', 'email', 'email', draftEmail); + const password = field('Passwort', 'password', 'password', 'current-password'); + const forgot = linkButton('Passwort vergessen?', () => showView('forgot')); + form.append(email.label, password.label, errorEl, okEl, submit('Anmelden'), el('p', { class: 'auth-aside' }, forgot)); + const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl }; + form.addEventListener('submit', (event) => { + event.preventDefault(); + if (busy) return; + const address = email.input.value.trim(); + void run(panel, 'login', async () => { + await login(address, password.input.value); + finish(true); + }); + }); + return panel; + } + + if (forView === 'register') { + const email = field('E-Mail', 'email', 'email', 'email', draftEmail); + const password = field('Passwort', 'password', 'password', 'new-password', '', 'auth-pw-hint'); + const repeat = field('Passwort wiederholen', 'password-repeat', 'password', 'new-password'); + const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, 'Mindestens 8 Zeichen.'); + form.append(email.label, password.label, hint, repeat.label, errorEl, okEl, submit('Registrieren')); + const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl }; + form.addEventListener('submit', (event) => { + event.preventDefault(); + if (busy) return; + const address = email.input.value.trim(); + const problem = password.input.value !== repeat.input.value ? MISMATCH : validatePassword(password.input.value); + if (problem) { + showError(panel, problem); + (problem === MISMATCH ? repeat : password).input.focus(); + return; + } + void run( + panel, + 'register', + async () => { + await register(address, password.input.value); + finish(true); + }, + (err) => { + if (errorType(err) !== 'user_already_exists') return undefined; + return linkButton('Anmelden', () => showView('login')); + }, + ); + }); + return panel; + } + + const email = field('E-Mail', 'email', 'email', 'email', draftEmail); + const back = linkButton('Zurück zur Anmeldung', () => showView('login')); + form.append(email.label, errorEl, okEl, submit('Link senden'), el('p', { class: 'auth-aside' }, back)); + const panel: Panel = { form, first: email.input, errorEl, okEl }; + form.addEventListener('submit', (event) => { + event.preventDefault(); + if (busy) return; + const address = email.input.value.trim(); + void run(panel, 'recover', async () => { + await requestPasswordReset(address); + showOk(panel, RESET_SENT); + }); + }); + return panel; + } + + /** Zeigt eine Ansicht; `focus` bestimmt, ob danach das erste Feld oder der gewählte Reiter den Fokus bekommt. */ + function showView(next: LoginView, focus: 'input' | 'tab' = 'input'): void { + // Die eingegebene E-Mail wandert beim Wechsel in die neue Ansicht mit; Passwörter nie. + const typed = content.querySelector('input[name="email"]'); + if (typed) draftEmail = typed.value.trim(); + view = next; + title.textContent = TITLES[next]; + const panel = buildPanel(next); + if (next === 'forgot') { + content.replaceChildren(panel.form); + } else { + const body = el('div', { id: 'auth-panel', role: 'tabpanel', 'aria-labelledby': `auth-tab-${next}` }, panel.form); + content.replaceChildren(buildTabs(), body); + } + if (focus === 'tab') content.querySelector(`#auth-tab-${next}`)?.focus(); + else panel.first.focus(); + } + + return (initial) => { + if (pending) return pending; + opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; + draftEmail = ''; + busy = false; + pending = new Promise((resolve) => { + settle = resolve; + }); + showView(initial); + dialog.showModal(); + // showModal fokussiert das erste bedienbare Element (den Schließen-Knopf); wir wollen das erste Eingabefeld. + content.querySelector('input')?.focus(); + return pending; + }; +} + +let open: ((view: LoginView) => Promise) | null = null; + +/** + * Öffnet den Login-Dialog (beim ersten Aufruf erzeugt). Gibt true zurück, sobald sich der Nutzer angemeldet + * oder registriert hat, und false, wenn er den Dialog schließt. Gleichzeitige Aufrufe teilen sich ein Ergebnis. + */ +export function requireLogin(view: LoginView = 'login'): Promise { + open ??= createLoginDialog(); + return open(view); +} diff --git a/web/src/main.ts b/web/src/main.ts index 8e894ed..2777128 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,24 +1,37 @@ import '@fontsource-variable/nunito-sans'; import './styles.css'; -import { startRouter, type RouteId } from './pages/router'; +import { mountAccountMenu } from './account/account-menu'; +import { getCurrentUser } from './account/auth'; +import { startRouter } from './pages/router'; +import { mountResetPage } from './pages/reset-page'; import { mountEditor } from './editor/editor-page'; import { createStore, initialEditorState } from './editor/state'; -// Platzhalter, bis die Seiten aus den Folgeaufgaben (Karten, Passwort) angebunden sind. -const PLACEHOLDER_TITLES: Record, string> = { - cards: 'Meine Karten', - reset: 'Passwort neu setzen', -}; - // Der Editor-Zustand lebt außerhalb der Seiten und überlebt Seitenwechsel. const editorStore = createStore(initialEditorState()); +// Konto-Menü im Seitenkopf. 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. +const accountSlot = document.getElementById('account-slot'); +if (accountSlot) { + const menu = mountAccountMenu(accountSlot); + getCurrentUser() + .catch((error: unknown) => { + console.error('Anmeldestatus konnte nicht geprüft werden', error); + }) + .finally(() => menu.ready()); +} + startRouter((route, root) => { if (route === 'editor') { // Speichern wird mit den Konten (spätere Aufgabe) angebunden; bis dahin passiert nichts. return mountEditor(root, editorStore, { onSave: () => {} }); } + if (route === 'reset') { + return mountResetPage(root); + } + // Platzhalter, bis die Karten-Seite aus der Folgeaufgabe angebunden ist. const heading = document.createElement('h1'); - heading.textContent = PLACEHOLDER_TITLES[route]; + heading.textContent = 'Meine Karten'; root.replaceChildren(heading); }); diff --git a/web/src/pages/reset-page.test.ts b/web/src/pages/reset-page.test.ts new file mode 100644 index 0000000..8520429 --- /dev/null +++ b/web/src/pages/reset-page.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + get: vi.fn(), + create: vi.fn(), + createEmailPasswordSession: vi.fn(), + deleteSession: vi.fn(), + createRecovery: vi.fn(), + updateRecovery: vi.fn(), +})); + +vi.mock('../account/appwrite', () => ({ account: mocks })); + +const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type }); + +type Page = typeof import('./reset-page'); +let mountResetPage: Page['mountResetPage']; +let root: HTMLElement; + +beforeEach(async () => { + for (const fn of Object.values(mocks)) fn.mockReset(); + vi.resetModules(); + document.body.innerHTML = '
'; + root = document.getElementById('app-root')!; + history.replaceState(null, '', '/passwort-neu'); + ({ mountResetPage } = await import('./reset-page')); +}); + +const withLink = () => history.replaceState(null, '', '/passwort-neu?userId=u1&secret=s3cret'); +const input = (name: string) => root.querySelector(`input[name="${name}"]`)!; +const alertText = () => root.querySelector('[role="alert"]')!.textContent; +const submit = () => root.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); +const fill = (password: string, repeat = password) => { + input('password').value = password; + input('password-repeat').value = repeat; +}; +const button = (text: string) => [...root.querySelectorAll('button')].find((b) => b.textContent === text)!; + +describe('ohne gültigen Link', () => { + it('ohne Parameter: Hinweis und Knopf „Neuen Link anfordern“, der den Dialog öffnet', () => { + mountResetPage(root); + expect(root.querySelector('h1')!.textContent).toBe('Passwort neu setzen'); + expect(alertText()).toBe('Der Link ist ungültig oder abgelaufen.'); + expect(root.querySelector('form')).toBeNull(); + button('Neuen Link anfordern').click(); + const dialog = document.querySelector('dialog.auth-dialog')!; + expect(dialog.open).toBe(true); + expect(dialog.querySelector('h2')!.textContent).toBe('Passwort vergessen'); + }); + + it('mit nur einem der beiden Parameter ebenso', () => { + history.replaceState(null, '', '/passwort-neu?userId=u1'); + mountResetPage(root); + expect(alertText()).toBe('Der Link ist ungültig oder abgelaufen.'); + }); +}); + +describe('mit Link', () => { + it('zeigt zwei Passwortfelder mit new-password', () => { + withLink(); + mountResetPage(root); + expect(input('password').autocomplete).toBe('new-password'); + expect(input('password-repeat').autocomplete).toBe('new-password'); + }); + + it('prüft vor dem Senden', () => { + withLink(); + mountResetPage(root); + fill('geheim123', 'geheim124'); + submit(); + expect(alertText()).toBe('Die Passwörter stimmen nicht überein.'); + fill('kurz'); + submit(); + expect(alertText()).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.'); + expect(input('password').getAttribute('aria-invalid')).toBe('true'); + expect(mocks.updateRecovery).not.toHaveBeenCalled(); + }); + + it('setzt das Passwort, zeigt den Erfolg, entfernt das Geheimnis aus der Adresse und bietet „Anmelden“', async () => { + withLink(); + mocks.updateRecovery.mockResolvedValue({}); + mountResetPage(root); + fill('neuesPasswort1'); + submit(); + await vi.waitFor(() => + expect(root.querySelector('[role="status"]')!.textContent).toBe('Passwort geändert. Du kannst dich jetzt anmelden.'), + ); + expect(mocks.updateRecovery).toHaveBeenCalledWith({ userId: 'u1', secret: 's3cret', password: 'neuesPasswort1' }); + expect(location.search).toBe(''); + button('Anmelden').click(); + const dialog = document.querySelector('dialog.auth-dialog')!; + expect(dialog.open).toBe(true); + expect(dialog.querySelector('h2')!.textContent).toBe('Anmelden'); + }); + + it('sperrt das Formular, solange die Anfrage läuft', async () => { + withLink(); + let release!: () => void; + mocks.updateRecovery.mockReturnValue(new Promise((resolve) => (release = resolve))); + mountResetPage(root); + fill('neuesPasswort1'); + submit(); + submit(); + expect(mocks.updateRecovery).toHaveBeenCalledTimes(1); + expect(root.querySelector('button[type="submit"]')!.disabled).toBe(true); + release(); + await vi.waitFor(() => expect(root.querySelector('[role="status"]')).not.toBeNull()); + }); + + it.each([ + ['user_invalid_token', 401, ''], + ['user_not_found', 404, ''], + ['general_argument_invalid', 400, 'Invalid `userId` param: Parameter must be a valid number'], + ['general_argument_invalid', 400, 'Invalid `secret` param: Value must be a valid string'], + ])('Link-Fehler %s (%s) zeigt „Link ungültig“ und den Knopf „Neuen Link anfordern“', async (type, code, message) => { + withLink(); + mocks.updateRecovery.mockRejectedValue(fail(code, type, message)); + mountResetPage(root); + fill('neuesPasswort1'); + submit(); + await vi.waitFor(() => expect(root.querySelector('form')).toBeNull()); + expect(alertText()).toBe('Der Link ist ungültig oder abgelaufen.'); + expect(button('Neuen Link anfordern')).toBeDefined(); + }); + + it('ein Passwortfehler vom Server steht am Feld, das Formular bleibt', async () => { + withLink(); + mocks.updateRecovery.mockRejectedValue( + fail( + 400, + 'general_argument_invalid', + 'Invalid `password` param: Password must be at least 8 characters and should not be one of the commonly used password.', + ), + ); + mountResetPage(root); + fill('password123'); + submit(); + await vi.waitFor(() => expect(alertText()).toBe('Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.')); + expect(root.querySelector('form')).not.toBeNull(); + expect(input('password').getAttribute('aria-invalid')).toBe('true'); + expect(root.querySelector('button[type="submit"]')!.disabled).toBe(false); + }); + + it('Netzfehler: Meldung, Formular bleibt', async () => { + withLink(); + mocks.updateRecovery.mockRejectedValue(new TypeError('Failed to fetch')); + mountResetPage(root); + fill('neuesPasswort1'); + submit(); + await vi.waitFor(() => expect(alertText()).toBe('Keine Verbindung zum Server.')); + expect(root.querySelector('form')).not.toBeNull(); + }); +}); diff --git a/web/src/pages/reset-page.ts b/web/src/pages/reset-page.ts new file mode 100644 index 0000000..50b320a --- /dev/null +++ b/web/src/pages/reset-page.ts @@ -0,0 +1,128 @@ +// Seite /passwort-neu: neues Passwort setzen mit dem Link aus der Mail (?userId=…&secret=…). +import '../account/account.css'; +import { completePasswordReset, validatePassword } from '../account/auth'; +import { errorMessage, isInvalidLinkError } from '../account/errors'; +import { requireLogin } from '../account/login-dialog'; +import { el } from '../editor/dom'; +import { navigate } from './router'; + +const MISMATCH = 'Die Passwörter stimmen nicht überein.'; + +export function mountResetPage(root: HTMLElement): () => void { + let disposed = false; + const params = new URLSearchParams(location.search); + const userId = params.get('userId') ?? ''; + const secret = params.get('secret') ?? ''; + + const heading = el('h1', {}, 'Passwort neu setzen'); + const page = el('section', { class: 'reset-page' }); + root.replaceChildren(heading, page); + + function showInvalidLink(): void { + const again = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Neuen Link anfordern'); + again.addEventListener('click', () => void requireLogin('forgot')); + page.replaceChildren( + el('p', { class: 'auth-msg auth-msg-error', role: 'alert' }, 'Der Link ist ungültig oder abgelaufen.'), + el('p', { class: 'reset-actions' }, again), + ); + } + + function showDone(): void { + // Das Geheimnis ist verbraucht; es soll nicht in der Adresszeile und im Verlauf stehen bleiben. + history.replaceState(null, '', location.pathname); + const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Anmelden'); + signIn.addEventListener('click', () => { + void requireLogin('login').then((ok) => { + if (ok && !disposed) navigate('/'); + }); + }); + page.replaceChildren( + el('p', { class: 'auth-msg auth-msg-ok', role: 'status' }, 'Passwort geändert. Du kannst dich jetzt anmelden.'), + el('p', { class: 'reset-actions' }, signIn), + ); + } + + function showForm(): void { + const password = el('input', { + class: 'auth-input', + type: 'password', + name: 'password', + autocomplete: 'new-password', + required: true, + 'aria-describedby': 'reset-hint reset-error', + }); + const repeat = el('input', { + class: 'auth-input', + type: 'password', + name: 'password-repeat', + autocomplete: 'new-password', + required: true, + 'aria-describedby': 'reset-error', + }); + const hint = el('p', { class: 'auth-hint', id: 'reset-hint' }, 'Mindestens 8 Zeichen.'); + const error = el('p', { class: 'auth-msg auth-msg-error', id: 'reset-error', role: 'alert' }); + const submit = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, 'Passwort speichern'); + const form = el( + 'form', + { class: 'auth-form' }, + el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, 'Neues Passwort'), password), + hint, + el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, 'Neues Passwort wiederholen'), repeat), + error, + submit, + ); + + let busy = false; + const setBusy = (value: boolean) => { + busy = value; + submit.disabled = value; + password.readOnly = value; + repeat.readOnly = value; + form.setAttribute('aria-busy', String(value)); + }; + const fail = (text: string, field: HTMLInputElement) => { + error.textContent = text; + password.setAttribute('aria-invalid', String(field === password)); + repeat.setAttribute('aria-invalid', String(field === repeat)); + field.focus(); + }; + + form.addEventListener('submit', (event) => { + event.preventDefault(); + if (busy) return; + error.textContent = ''; + password.removeAttribute('aria-invalid'); + repeat.removeAttribute('aria-invalid'); + const problem = password.value !== repeat.value ? MISMATCH : validatePassword(password.value); + if (problem) { + fail(problem, problem === MISMATCH ? repeat : password); + return; + } + setBusy(true); + completePasswordReset(userId, secret, password.value).then( + () => { + if (!disposed) showDone(); + }, + (err: unknown) => { + if (disposed) return; + // Link-Fehler ersetzen das Formular; Passwort-, Netz- und sonstige Fehler stehen am Feld. + if (isInvalidLinkError(err)) { + showInvalidLink(); + return; + } + setBusy(false); + fail(errorMessage(err, 'reset'), password); + }, + ); + }); + + page.replaceChildren(form); + } + + if (userId && secret) showForm(); + else showInvalidLink(); + + return () => { + disposed = true; + }; +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts index 11f02fe..2fe3584 100644 --- a/web/src/vite-env.d.ts +++ b/web/src/vite-env.d.ts @@ -1 +1,12 @@ /// + +interface ImportMetaEnv { + /** Appwrite-Endpoint; ohne Angabe `${location.origin}/v1` (Produktion: https://aircard-ios.org/v1). */ + readonly VITE_APPWRITE_ENDPOINT?: string; + /** Appwrite-Projekt-ID; ohne Angabe `aircard`. */ + readonly VITE_APPWRITE_PROJECT_ID?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +}