feat: add user dropdown menu, password management, fix Turnstile keys

- Replace inline nav items with UserMenu dropdown (display name trigger,
  Profil/Sicherheit/Admin/Abmelden, click-outside/Escape to close)
- Add password set/change form to profile security section
- Fix Turnstile site key (extra A, swapped l/1)
This commit is contained in:
2026-02-27 14:39:01 +01:00
parent bb6912d94d
commit dd4e6184ac
7 changed files with 186 additions and 22 deletions

View File

@@ -29,7 +29,7 @@ steps:
from_secret: registry_password
build_args:
- PUBLIC_API_BASE_URL=https://api.marktvogt.de
- PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAACjLCV-78Q1loTPz
- PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAACjLCV-78Ql1oTPz
when:
- event: push
branch: main

View File

@@ -62,7 +62,7 @@ config:
PORT: "3000"
HOST: "0.0.0.0"
# Cloudflare Turnstile — read at runtime via $env/dynamic/public
PUBLIC_TURNSTILE_SITE_KEY: "0x4AAAAAAACjLCV-78Q1loTPz"
PUBLIC_TURNSTILE_SITE_KEY: "0x4AAAAAACjLCV-78Ql1oTPz"
nodeSelector: {}
tolerations: []

View File

@@ -94,6 +94,7 @@ export interface ProfileData {
display_name: string;
avatar_url: string;
role: string;
has_password: boolean;
created_at: string;
}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import type { ProfileData } from '$lib/api/types.js';
import MobileNav from './MobileNav.svelte';
import UserMenu from './UserMenu.svelte';
interface Props {
user: ProfileData | null;
@@ -39,21 +40,8 @@
<a href="/markt/einreichen" class="text-primary-200 text-sm font-medium hover:text-white">
Markt einreichen
</a>
{#if user?.role === 'admin'}
<a href="/admin/maerkte" class="text-accent-300 hover:text-accent-200 text-sm font-medium">
Admin
</a>
{/if}
{#if user}
<a href="/profile" class="text-primary-200 text-sm font-medium hover:text-white">
Profil
</a>
<form method="POST" action="/auth/abmelden">
<button type="submit" class="text-primary-200 text-sm font-medium hover:text-white">
Abmelden
</button>
</form>
<span class="text-primary-300 text-sm">{user.display_name}</span>
<UserMenu {user} />
{:else}
<a href="/auth/anmelden" class="text-accent-300 hover:text-accent-200 text-sm font-medium">
Anmelden

View File

@@ -0,0 +1,105 @@
<script lang="ts">
import type { ProfileData } from '$lib/api/types.js';
interface Props {
user: ProfileData;
}
let { user }: Props = $props();
let open = $state(false);
let menuRef = $state<HTMLDivElement | null>(null);
function toggle() {
open = !open;
}
function close() {
open = false;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') close();
}
$effect(() => {
if (!open) return;
function handlePointerDown(e: PointerEvent) {
if (menuRef && !menuRef.contains(e.target as Node)) {
close();
}
}
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="relative" bind:this={menuRef} onkeydown={onKeydown}>
<button
type="button"
class="text-primary-200 flex items-center gap-1 text-sm font-medium hover:text-white"
onclick={toggle}
aria-expanded={open}
aria-haspopup="true"
>
{user.display_name}
<svg
class="h-4 w-4 transition-transform {open ? 'rotate-180' : ''}"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
{#if open}
<div
class="bg-primary-800 ring-primary-700 absolute right-0 z-50 mt-2 w-48 rounded-md py-1 shadow-lg ring-1"
role="menu"
>
<a
href="/profile"
class="text-primary-100 hover:bg-primary-700 block px-4 py-2 text-sm"
role="menuitem"
onclick={close}
>
Profil
</a>
<a
href="/profile/security"
class="text-primary-100 hover:bg-primary-700 block px-4 py-2 text-sm"
role="menuitem"
onclick={close}
>
Sicherheit
</a>
{#if user.role === 'admin'}
<div class="border-primary-700 my-1 border-t"></div>
<a
href="/admin/maerkte"
class="text-accent-300 hover:bg-primary-700 block px-4 py-2 text-sm"
role="menuitem"
onclick={close}
>
Admin
</a>
{/if}
<div class="border-primary-700 my-1 border-t"></div>
<form method="POST" action="/auth/abmelden">
<button
type="submit"
class="text-primary-100 hover:bg-primary-700 block w-full px-4 py-2 text-left text-sm"
role="menuitem"
>
Abmelden
</button>
</form>
</div>
{/if}
</div>

View File

@@ -38,6 +38,38 @@ export const actions: Actions = {
}
},
password: async ({ request, cookies, fetch }) => {
const form = await request.formData();
const currentPassword = form.get('current_password') as string;
const newPassword = form.get('new_password') as string;
const confirmPassword = form.get('confirm_password') as string;
if (!newPassword || newPassword.length < 8) {
return fail(400, { error: 'Passwort muss mindestens 8 Zeichen lang sein.' });
}
if (newPassword !== confirmPassword) {
return fail(400, { error: 'Passwörter stimmen nicht überein.' });
}
const body: Record<string, string> = { new_password: newPassword };
if (currentPassword) body.current_password = currentPassword;
try {
await serverFetch('/auth/password', cookies, {
method: 'PUT',
body: JSON.stringify(body),
fetch
});
return { success: 'Passwort aktualisiert.' };
} catch (e) {
if (e instanceof ApiClientError) {
return fail(e.status, { error: e.message });
}
return fail(500, { error: 'Ein Fehler ist aufgetreten.' });
}
},
delete: async ({ cookies, fetch }) => {
try {
await serverFetch('/users/me', cookies, {

View File

@@ -8,6 +8,7 @@
let showDeleteConfirm = $state(false);
let updateLoading = $state(false);
let passwordLoading = $state(false);
let deleteLoading = $state(false);
</script>
@@ -66,12 +67,49 @@
<!-- Security -->
<div class="bg-vellum rounded-lg border border-stone-200 p-6 shadow-sm dark:border-stone-700">
<h2 class="mb-4 text-lg font-semibold text-stone-900 dark:text-stone-100">Sicherheit</h2>
<a
href="/profile/security"
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 text-sm font-medium"
>
Zwei-Faktor-Authentifizierung verwalten
</a>
<div class="space-y-6">
<!-- Password -->
<div>
<h3 class="mb-3 text-sm font-semibold text-stone-800 dark:text-stone-200">
{data.profile.has_password ? 'Passwort ändern' : 'Passwort festlegen'}
</h3>
<form
method="POST"
action="?/password"
use:enhance={() => {
passwordLoading = true;
return async ({ update }) => {
passwordLoading = false;
await update();
};
}}
class="space-y-4"
>
{#if data.profile.has_password}
<Input name="current_password" label="Aktuelles Passwort" type="password" required />
{/if}
<Input name="new_password" label="Neues Passwort" type="password" required />
<Input name="confirm_password" label="Passwort bestätigen" type="password" required />
<Button type="submit" loading={passwordLoading}>
{data.profile.has_password ? 'Passwort ändern' : 'Passwort festlegen'}
</Button>
</form>
</div>
<!-- 2FA -->
<div class="border-t border-stone-200 pt-4 dark:border-stone-700">
<a
href="/profile/security"
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 text-sm font-medium"
>
Zwei-Faktor-Authentifizierung verwalten
</a>
</div>
</div>
</div>
<!-- Danger zone -->