feat: implement authentication flow

Backend:
- AppConfig, AppError, AppState modules for shared infrastructure
- JWT creation/verification with HS256 (jsonwebtoken crate)
- Session management: SHA-256 token hashing, DB-backed sessions
- Auth middleware: AuthUser, RequireHost, RequireAdmin extractors
- POST /api/v1/join: name-only registration, 4-digit PIN + bcrypt hash
- POST /api/v1/recover: PIN-based recovery with 3-attempt lockout (15 min)
- POST /api/v1/admin/login: bcrypt password verification
- DELETE /api/v1/session: logout (session invalidation)
- Migration 006: user PIN lockout columns (failed_pin_attempts, pin_locked_until)
- Models: Event, User (with role enum), Session with all CRUD methods

Frontend:
- api.ts: typed fetch wrapper with automatic Bearer token injection
- auth.ts: JWT/PIN localStorage management with Svelte store
- /join: name entry form with PIN display modal and copy button
- /recover: name + PIN recovery form with saved PIN pre-fill
- /feed: placeholder gallery page with logout
- Root layout: auth initialization on mount
- Root page: redirect to /join or /feed based on auth state

All responses use German language strings as specified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-03-31 21:44:03 +02:00
parent e976f0f670
commit 8b9d916265
23 changed files with 1118 additions and 11 deletions

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api';
import { setAuth, getPin } from '$lib/auth';
import { browser } from '$app/environment';
let displayName = $state('');
let pin = $state('');
let error = $state('');
let loading = $state(false);
// Pre-fill PIN from localStorage if available
if (browser) {
const savedPin = getPin();
if (savedPin) pin = savedPin;
}
async function handleRecover() {
if (!displayName.trim() || !pin.trim()) return;
loading = true;
error = '';
try {
const res = await api.post<{
jwt: string;
user_id: string;
}>('/recover', { display_name: displayName.trim(), pin: pin.trim() });
setAuth(res.jwt, pin.trim(), res.user_id);
goto('/feed');
} catch (e) {
if (e instanceof ApiError) {
error = e.message;
} else {
error = 'Ein Fehler ist aufgetreten.';
}
} finally {
loading = false;
}
}
</script>
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div class="w-full max-w-sm">
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900">Konto wiederherstellen</h1>
<p class="mb-6 text-center text-gray-600">Gib deinen Namen und deinen PIN ein.</p>
<form onsubmit={(e) => { e.preventDefault(); handleRecover(); }}>
<input
type="text"
bind:value={displayName}
placeholder="Dein Name"
maxlength={50}
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
/>
<input
type="text"
bind:value={pin}
placeholder="4-stelliger PIN"
maxlength={4}
inputmode="numeric"
pattern="[0-9]*"
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-center text-2xl font-mono tracking-widest focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
/>
{#if error}
<p class="mb-3 text-sm text-red-600">{error}</p>
{/if}
<button
type="submit"
disabled={loading || !displayName.trim() || pin.length < 4}
class="w-full rounded-lg bg-blue-600 px-4 py-3 text-lg font-medium text-white transition hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Wird geladen...' : 'Wiederherstellen'}
</button>
</form>
<p class="mt-4 text-center text-sm text-gray-500">
Noch kein Konto?
<a href="/join" class="text-blue-600 hover:underline">Neu beitreten</a>
</p>
</div>
</div>