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

+157
View File
@@ -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);
});
});