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:
1 parent
d1d4d621a7
commit
10c55ca933
15 files changed
+1866
-10
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user