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:
@@ -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
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -94,6 +94,7 @@ export interface ProfileData {
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
role: string;
|
||||
has_password: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
105
web/src/lib/components/layout/UserMenu.svelte
Normal file
105
web/src/lib/components/layout/UserMenu.svelte
Normal 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>
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user