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

+234
View File
@@ -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<Auth> {
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: '<!doctype html>' });
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.');
});
});