Rollen: Trainer-Einladung, rollenbasierte Navigation und Zugriffskontrolle

This commit is contained in:
rene 2026-05-20 17:27:59 +02:00
parent 7e2e5a643d
commit 59aa3cbcce
6 changed files with 413 additions and 9 deletions

View file

@ -0,0 +1,178 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { pb } from '$lib/pb';
import type { Einladung } from '$lib/types';
const token = $derived($page.params.token as string);
let einladung = $state<Einladung | null>(null);
let vereinName = $state('');
let fehler = $state('');
let loading = $state(true);
let name = $state('');
let email = $state('');
let password = $state('');
let passwordConfirm = $state('');
let saving = $state(false);
let formError = $state('');
onMount(async () => {
if (pb.authStore.isValid) {
goto('/');
return;
}
try {
const inv = await pb.collection('einladungen')
.getFirstListItem<Einladung>(`token = "${token}" && genutzt = false`, {
expand: 'verein_id',
});
einladung = inv;
vereinName = (inv as any).expand?.verein_id?.name ?? '';
} catch {
fehler = 'Dieser Einladungslink ist ungültig oder wurde bereits verwendet.';
} finally {
loading = false;
}
});
async function registrieren() {
if (!einladung) return;
if (password !== passwordConfirm) { formError = 'Passwörter stimmen nicht überein.'; return; }
formError = ''; saving = true;
try {
await pb.collection('users').create({
email: email.trim(),
password,
passwordConfirm,
name: name.trim(),
verein_id: einladung.verein_id,
rolle: einladung.rolle,
});
await pb.collection('users').authWithPassword(email.trim(), password);
await pb.collection('einladungen').update(einladung.id, { genutzt: true });
goto('/');
} catch (e: unknown) {
formError = e instanceof Error ? e.message : 'Registrierung fehlgeschlagen.';
} finally {
saving = false;
}
}
</script>
<svelte:head><title>Einladung — vereins.haus</title></svelte:head>
<div class="shell">
<div class="logo">
<img src="/favicon.svg" alt="" width="36" height="36" />
vereins.haus
</div>
<div class="card">
{#if loading}
<p class="hint">Prüfe Einladung…</p>
{:else if fehler}
<div class="icon-ring error"></div>
<h1>Ungültige Einladung</h1>
<p class="beschr">{fehler}</p>
{:else}
<div class="icon-ring">🎉</div>
<h1>Du wurdest eingeladen!</h1>
<p class="beschr">
Du wirst als <strong>{einladung?.rolle === 'trainer' ? 'Trainer' : 'Admin'}</strong>
zum Verein <strong>{vereinName}"</strong> hinzugefügt.
</p>
<form onsubmit={(e) => { e.preventDefault(); registrieren(); }}>
<div class="field">
<label for="name">Dein Name *</label>
<input id="name" type="text" bind:value={name} required autocomplete="name" />
</div>
<div class="field">
<label for="email">E-Mail *</label>
<input id="email" type="email" bind:value={email} required autocomplete="email" />
</div>
<div class="field">
<label for="pw">Passwort *</label>
<input id="pw" type="password" bind:value={password} required minlength="8" autocomplete="new-password" />
</div>
<div class="field">
<label for="pw2">Passwort bestätigen *</label>
<input id="pw2" type="password" bind:value={passwordConfirm} required autocomplete="new-password" />
</div>
{#if formError}
<p class="error">{formError}</p>
{/if}
<button type="submit" class="btn-primary" disabled={saving || !name || !email || !password}>
{saving ? 'Registrieren…' : 'Konto erstellen & beitreten'}
</button>
</form>
{/if}
</div>
</div>
<style>
.shell {
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem 1rem;
background: #f8fafc;
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.2rem;
font-weight: 700;
color: #0f172a;
letter-spacing: -0.02em;
margin-bottom: 1.5rem;
}
.card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 2rem 1.5rem;
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.icon-ring {
width: 3.5rem; height: 3.5rem; border-radius: 50%;
background: #e0e7ff; display: flex;
align-items: center; justify-content: center;
font-size: 1.5rem; margin: 0 auto;
}
.icon-ring.error { background: #fee2e2; }
h1 { font-size: 1.2rem; font-weight: 700; color: #1e293b; text-align: center; margin: 0; }
.beschr { font-size: 0.9rem; color: #64748b; text-align: center; line-height: 1.55; margin: 0; }
.hint { color: #94a3b8; text-align: center; }
.field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.5rem; }
label { font-size: 0.875rem; font-weight: 500; color: #475569; }
input {
padding: 0.65rem 0.85rem;
border: 1.5px solid #e2e8f0; border-radius: 8px;
font-size: 1rem; background: #fff; width: 100%; box-sizing: border-box;
transition: border-color 0.15s;
}
input:focus { outline: none; border-color: #1e40af; }
.error { color: #dc2626; font-size: 0.875rem; margin: 0; }
.btn-primary {
width: 100%; padding: 0.75rem; background: #1e40af; color: #fff;
border: none; border-radius: 8px; font-size: 1rem; font-weight: 600;
cursor: pointer; margin-top: 0.25rem; transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) { background: #1d3a9e; }
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
</style>