feat(account): Login, Registrierung, Passwort zurücksetzen

- appwrite 22.4.1 (exakt gepinnt); Client mit Endpoint aus VITE_APPWRITE_ENDPOINT
  bzw. location.origin + /v1, Projekt aus VITE_APPWRITE_PROJECT_ID bzw. aircard
- auth.ts: getCurrentUser (401 -> null, ungültige Antwort -> Fehler), login, register,
  logout, Passwort-Link anfordern/abschließen, onAuthChange, validatePassword
- errors.ts: deutsche Meldungen nach type, dann code (Tabelle aus dem Plan)
- Login-Dialog (Anmelden/Registrieren/Passwort vergessen) als gemeinsames <dialog>,
  Konto-Menü im Seitenkopf, Seite /passwort-neu
- Tests: errors, auth, Dialog, Passwort-neu-Seite

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 08:54:10 +00:00
1 parent d1d4d621a7
commit 10c55ca933
15 files changed
+1866 -10

No files matched your search

+153
View File
@@ -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 = '<main id="app-root"></main>';
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<HTMLInputElement>(`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<HTMLDialogElement>('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<HTMLDialogElement>('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<void>((resolve) => (release = resolve)));
mountResetPage(root);
fill('neuesPasswort1');
submit();
submit();
expect(mocks.updateRecovery).toHaveBeenCalledTimes(1);
expect(root.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('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();
});
});
+128
View File
@@ -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;
};
}