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

@@ -45,6 +45,10 @@ export const actions = {
return fail(401, { error: getErrorMessage(code) });
}
if (response.status === 429) {
return fail(429, { error: getErrorMessage('TOO_MANY_LOGIN_ATTEMPTS'), rateLimited: true });
}
if (!response.ok) {
return fail(response.status, { error: getErrorMessage('INTERNAL_ERROR') });
}

View File

@@ -7,7 +7,7 @@ let {
form
}: {
data: { registered: boolean; reason?: string | null };
form?: { error?: string; success?: boolean };
form?: { error?: string; rateLimited?: boolean; success?: boolean };
} = $props();
</script>
@@ -106,7 +106,31 @@ let {
</div>
{#if form?.error}
<div class="text-center font-sans text-xs font-medium text-red-600">{form.error}</div>
{#if form?.rateLimited}
<div
aria-invalid="true"
role="alert"
class="flex items-center gap-2 font-sans text-xs font-medium text-red-600"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="h-4 w-4 shrink-0 text-ink-3"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
<span>{form.error}</span>
</div>
{:else}
<div class="text-center font-sans text-xs font-medium text-red-600">{form.error}</div>
{/if}
{/if}
<button

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();