feat(frontend): add CSRF injection, rate-limit i18n, and 429 login handling

- handleFetch injects X-XSRF-TOKEN + XSRF-TOKEN cookie on all mutating
  backend API requests (double-submit cookie pattern); generates a fresh
  UUID when no XSRF-TOKEN cookie exists yet
- ErrorCode union gains CSRF_TOKEN_MISSING and TOO_MANY_LOGIN_ATTEMPTS;
  getErrorMessage maps both to i18n keys
- de/en/es messages add error_csrf_token_missing and
  error_too_many_login_attempts translations
- Login action maps HTTP 429 to fail(429, { ..., rateLimited: true });
  page shows a muted clock icon with aria-invalid on rate-limit errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-18 12:59:56 +02:00
committed by marcel
parent 4d6fb06e02
commit 78fd9e026e
8 changed files with 105 additions and 30 deletions

View File

@@ -100,6 +100,25 @@ describe('login action', () => {
expect(cookies.delete).toHaveBeenCalledWith('auth_token', { path: '/' });
});
it('returns 429 with rateLimited=true when the backend rate-limits the request', async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: 'TOO_MANY_LOGIN_ATTEMPTS' }), {
status: 429,
headers: { 'Content-Type': 'application/json' }
})
);
const result = await (actions as ActionsRecord).login({
request: makeRequest({ email: 'a@b.de', password: 'pw' }),
cookies: makeCookies(),
fetch: mockFetch,
url: new URL('http://localhost/login')
} as never);
expect((result as { status: number }).status).toBe(429);
expect((result as { data: { rateLimited: boolean } }).data.rateLimited).toBe(true);
});
it('returns 500 when backend response omits fa_session cookie', async () => {
const mockFetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
const cookies = makeCookies();