1 Commits

Author SHA1 Message Date
MechaCat02
75d186fad3 feat: implement My Account page
Add /account route showing display name (from localStorage), role badge,
session expiry decoded from JWT, and recovery PIN display with copy button.
Join and recover flows now persist display_name to localStorage via setAuth().
Feed header logout button replaced with person-icon link to /account;
logout is available from the account page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 21:11:11 +02:00
5 changed files with 167 additions and 13 deletions

View File

@@ -4,6 +4,7 @@ import { browser } from '$app/environment';
const TOKEN_KEY = 'eventsnap_jwt'; const TOKEN_KEY = 'eventsnap_jwt';
const PIN_KEY = 'eventsnap_pin'; const PIN_KEY = 'eventsnap_pin';
const USER_ID_KEY = 'eventsnap_user_id'; const USER_ID_KEY = 'eventsnap_user_id';
const DISPLAY_NAME_KEY = 'eventsnap_display_name';
export const isAuthenticated = writable(false); export const isAuthenticated = writable(false);
@@ -22,11 +23,28 @@ export function getUserId(): string | null {
return localStorage.getItem(USER_ID_KEY); return localStorage.getItem(USER_ID_KEY);
} }
export function setAuth(jwt: string, pin: string | null, userId: string): void { export function getDisplayName(): string | null {
if (!browser) return null;
return localStorage.getItem(DISPLAY_NAME_KEY);
}
export function getExpiry(): Date | null {
const token = getToken();
if (!token) return null;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp ? new Date(payload.exp * 1000) : null;
} catch {
return null;
}
}
export function setAuth(jwt: string, pin: string | null, userId: string, displayName?: string): void {
if (!browser) return; if (!browser) return;
localStorage.setItem(TOKEN_KEY, jwt); localStorage.setItem(TOKEN_KEY, jwt);
if (pin) localStorage.setItem(PIN_KEY, pin); if (pin) localStorage.setItem(PIN_KEY, pin);
localStorage.setItem(USER_ID_KEY, userId); localStorage.setItem(USER_ID_KEY, userId);
if (displayName) localStorage.setItem(DISPLAY_NAME_KEY, displayName);
isAuthenticated.set(true); isAuthenticated.set(true);
} }

View File

@@ -0,0 +1,137 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken, getPin, getDisplayName, getExpiry, getRole, clearAuth } from '$lib/auth';
import { api } from '$lib/api';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
let pin = $state<string | null>(null);
let displayName = $state<string | null>(null);
let role = $state<'guest' | 'host' | 'admin' | null>(null);
let expiry = $state<Date | null>(null);
let copied = $state(false);
let pinCopied = $state(false);
onMount(() => {
if (!getToken()) {
goto('/join');
return;
}
pin = getPin();
displayName = getDisplayName();
role = getRole();
expiry = getExpiry();
});
function copyPin() {
if (!pin) return;
navigator.clipboard.writeText(pin);
pinCopied = true;
setTimeout(() => (pinCopied = false), 2000);
}
async function handleLogout() {
try { await api.delete('/session'); } catch { /* ignore */ }
clearAuth();
goto('/join');
}
function formatDate(d: Date): string {
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
}
function roleLabel(r: string | null): string {
switch (r) {
case 'admin': return 'Admin';
case 'host': return 'Gastgeber';
default: return 'Gast';
}
}
function roleColor(r: string | null): string {
switch (r) {
case 'admin': return 'bg-red-100 text-red-700';
case 'host': return 'bg-purple-100 text-purple-700';
default: return 'bg-blue-100 text-blue-700';
}
}
</script>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<div class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-lg items-center justify-between px-4 py-4">
<h1 class="text-xl font-bold text-gray-900">Mein Konto</h1>
<a href="/feed" class="text-sm text-blue-600 hover:underline">Zur Galerie</a>
</div>
</div>
<div class="mx-auto max-w-lg space-y-4 p-4">
<!-- Profile card -->
<div class="rounded-xl border border-gray-200 bg-white p-5">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 text-xl font-bold text-blue-600">
{#if displayName}
{displayName[0].toUpperCase()}
{:else}
?
{/if}
</div>
<div>
<p class="font-semibold text-gray-900">{displayName ?? 'Unbekannt'}</p>
<span class="inline-block rounded-full px-2 py-0.5 text-xs font-medium {roleColor(role)}">
{roleLabel(role)}
</span>
</div>
</div>
{#if expiry}
<p class="mt-3 text-xs text-gray-400">Sitzung gültig bis {formatDate(expiry)}</p>
{/if}
</div>
<!-- PIN card -->
<div class="rounded-xl border border-amber-200 bg-amber-50 p-5">
<h2 class="mb-1 font-semibold text-amber-900">Wiederherstellungs-PIN</h2>
<p class="mb-3 text-sm text-amber-700">
Du brauchst diesen PIN, um dein Konto auf einem anderen Gerät wiederherzustellen. Schreib ihn auf!
</p>
{#if pin}
<div class="flex items-center justify-between rounded-lg bg-white px-4 py-3 shadow-sm">
<span class="font-mono text-4xl font-bold tracking-widest text-gray-900">{pin}</span>
<button
onclick={copyPin}
class="rounded-md bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-800 transition hover:bg-amber-200"
>
{pinCopied ? 'Kopiert!' : 'Kopieren'}
</button>
</div>
{:else}
<div class="rounded-lg bg-white px-4 py-3 text-sm text-gray-400 shadow-sm">
PIN nicht gespeichert. Nutze die Wiederherstellungs-Seite, um dich mit deinem PIN anzumelden.
</div>
{/if}
</div>
<!-- Recovery hint -->
<div class="rounded-xl border border-gray-200 bg-white p-5">
<h2 class="mb-1 font-semibold text-gray-900">Gerät wechseln?</h2>
<p class="text-sm text-gray-600">
Auf einem anderen Gerät kannst du dein Konto mit deinem Namen und PIN wiederherstellen.
</p>
<a
href="/recover"
class="mt-3 inline-block text-sm font-medium text-blue-600 hover:underline"
>
Zur Wiederherstellungs-Seite →
</a>
</div>
<!-- Logout -->
<button
onclick={handleLogout}
class="w-full rounded-xl border border-red-200 bg-white py-3 text-sm font-medium text-red-600 transition hover:bg-red-50"
>
Abmelden
</button>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { getToken, clearAuth } from '$lib/auth'; import { getToken } from '$lib/auth';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse'; import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
@@ -152,11 +152,7 @@
if (u) selectedUpload = u; if (u) selectedUpload = u;
} }
async function handleLogout() {
try { await api.delete('/session'); } catch { /* ignore */ }
clearAuth();
goto('/join');
}
</script> </script>
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
@@ -171,12 +167,15 @@
> >
Hochladen Hochladen
</a> </a>
<button <a
onclick={handleLogout} href="/account"
class="text-sm text-gray-500 hover:text-gray-700" class="text-sm text-gray-500 hover:text-gray-700"
aria-label="Mein Konto"
> >
Abmelden <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
</button> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
</a>
</div> </div>
</div> </div>

View File

@@ -22,7 +22,7 @@
is_new: boolean; is_new: boolean;
}>('/join', { display_name: displayName.trim() }); }>('/join', { display_name: displayName.trim() });
setAuth(res.jwt, res.pin, res.user_id); setAuth(res.jwt, res.pin, res.user_id, displayName.trim());
pin = res.pin; pin = res.pin;
showPinModal = true; showPinModal = true;
} catch (e) { } catch (e) {

View File

@@ -25,7 +25,7 @@
user_id: string; user_id: string;
}>('/recover', { display_name: displayName.trim(), pin: pin.trim() }); }>('/recover', { display_name: displayName.trim(), pin: pin.trim() });
setAuth(res.jwt, pin.trim(), res.user_id); setAuth(res.jwt, pin.trim(), res.user_id, displayName.trim());
goto('/feed'); goto('/feed');
} catch (e) { } catch (e) {
if (e instanceof ApiError) { if (e instanceof ApiError) {