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

@ -42,11 +42,22 @@ export interface Mitglied {
mandatsdatum?: string;
}
export type Rolle = 'admin' | 'trainer';
export interface Gruppe {
id: string;
verein_id: string;
name: string;
beschreibung?: string;
trainer_ids: string[];
}
export interface Einladung {
id: string;
verein_id: string;
rolle: Rolle;
token: string;
genutzt: boolean;
}
export interface Beitrag {

View file

@ -67,14 +67,18 @@
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}
const navItems: { href: string; label: string; icon: IconName }[] = [
const isAdmin = () => !pb.authStore.record?.rolle || pb.authStore.record?.rolle === 'admin';
const allNavItems: { href: string; label: string; icon: IconName; adminOnly?: boolean }[] = [
{ href: '/', label: 'Übersicht', icon: 'house' },
{ href: '/mitglieder', label: 'Mitglieder', icon: 'users' },
{ href: '/termine', label: 'Termine', icon: 'calendar' },
{ href: '/beitraege', label: 'Beiträge', icon: 'currency-eur' },
{ href: '/beitraege', label: 'Beiträge', icon: 'currency-eur', adminOnly: true },
{ href: '/nachrichten', label: 'Nachrichten', icon: 'envelope' },
{ href: '/einstellungen', label: 'Einstellungen', icon: 'gear' },
];
const navItems = $derived(allNavItems.filter(i => !i.adminOnly || isAdmin()));
</script>
<div class="shell">

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { generatePain008, downloadXml, minEinzugsdatum, type SepaPosition } from '$lib/sepa';
import type { Beitrag, Mitglied, Verein } from '$lib/types';
@ -35,6 +36,7 @@
};
onMount(async () => {
if (pb.authStore.record?.rolle === 'trainer') { goto('/'); return; }
const vid = pb.authStore.record?.verein_id as string;
[beitraege, verein] = await Promise.all([
pb.collection('beitraege').getFullList<Beitrag>({ sort: 'name' }),

View file

@ -2,7 +2,9 @@
import { pb } from '$lib/pb';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import type { Verein } from '$lib/types';
import type { Verein, Gruppe } from '$lib/types';
const isAdmin = () => !pb.authStore.record?.rolle || pb.authStore.record?.rolle === 'admin';
let loading = $state(true);
let saving = $state(false);
@ -24,6 +26,12 @@
let iban = $state('');
let bic = $state('');
// Trainer
let trainer = $state<any[]>([]);
let gruppen = $state<Gruppe[]>([]);
let einladungUrl = $state('');
let einladungKopiert = $state(false);
let vereinId = '';
const bundeslaender = [
@ -38,7 +46,15 @@
onMount(async () => {
vereinId = pb.authStore.record?.verein_id as string;
const v = await pb.collection('vereine').getOne<Verein>(vereinId);
const [v, alleUser, alleGruppen] = await Promise.all([
pb.collection('vereine').getOne<Verein>(vereinId),
isAdmin()
? pb.collection('users').getFullList({ filter: `verein_id = "${vereinId}"` })
: Promise.resolve([]),
pb.collection('gruppen').getFullList<Gruppe>({ sort: 'name' }),
]);
trainer = alleUser.filter((u: any) => u.rolle === 'trainer');
gruppen = alleGruppen;
name = v.name ?? '';
adresse = v.adresse ?? '';
plz = v.plz ?? '';
@ -78,6 +94,28 @@
}
}
async function trainerEinladen() {
const token = crypto.randomUUID().replace(/-/g, '');
await pb.collection('einladungen').create({
verein_id: vereinId, rolle: 'trainer', token, genutzt: false,
});
einladungUrl = `${window.location.origin}/invite/${token}`;
einladungKopiert = false;
}
async function trainerEntfernen(uid: string) {
if (!confirm('Trainer wirklich entfernen?')) return;
await pb.collection('users').update(uid, { rolle: null, verein_id: null });
trainer = trainer.filter(t => t.id !== uid);
}
function trainerGruppen(uid: string): string {
return gruppen
.filter(g => (g.trainer_ids ?? []).includes(uid))
.map(g => g.name)
.join(', ') || '—';
}
function abmelden() {
pb.authStore.clear();
goto('/login');
@ -168,6 +206,44 @@
</div>
</section>
{#if isAdmin()}
<section>
<h2>Trainer</h2>
{#if trainer.length === 0}
<p class="sepa-hint">Noch keine Trainer eingeladen.</p>
{:else}
<ul class="trainer-liste">
{#each trainer as t (t.id)}
<li>
<div class="trainer-info">
<span class="trainer-name">{t.name}</span>
<span class="trainer-gruppen">{trainerGruppen(t.id)}</span>
</div>
<button type="button" class="btn-remove" onclick={() => trainerEntfernen(t.id)}>Entfernen</button>
</li>
{/each}
</ul>
{/if}
<button type="button" class="btn-einladen" onclick={trainerEinladen}>
+ Trainer einladen
</button>
{#if einladungUrl}
<div class="invite-box">
<p class="invite-label">Einladungslink einmalig verwendbar:</p>
<div class="invite-url">{einladungUrl}</div>
<button type="button" class="btn-kopieren" onclick={async () => {
await navigator.clipboard.writeText(einladungUrl);
einladungKopiert = true;
}}>
{einladungKopiert ? 'Kopiert ✓' : 'Link kopieren'}
</button>
</div>
{/if}
</section>
{/if}
{#if error}
<p class="error">{error}</p>
{/if}
@ -252,6 +328,47 @@
margin-bottom: 1.5rem;
}
.trainer-liste {
list-style: none; padding: 0; margin: 0 0 0.75rem;
border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden;
}
.trainer-liste li {
display: flex; align-items: center; justify-content: space-between;
gap: 0.75rem; padding: 0.7rem 0.85rem;
border-bottom: 1px solid #f1f5f9;
}
.trainer-liste li:last-child { border-bottom: none; }
.trainer-info { display: flex; flex-direction: column; gap: 0.1rem; }
.trainer-name { font-size: 0.9rem; font-weight: 600; color: #1e293b; }
.trainer-gruppen { font-size: 0.75rem; color: #94a3b8; }
.btn-remove {
padding: 0.3rem 0.65rem; background: none;
border: 1px solid #fca5a5; border-radius: 6px;
font-size: 0.78rem; color: #dc2626; cursor: pointer; flex-shrink: 0;
}
.btn-einladen {
width: 100%; padding: 0.65rem; background: none;
border: 1.5px dashed #e2e8f0; border-radius: 8px;
font-size: 0.9rem; color: #1e40af; cursor: pointer;
transition: border-color 0.15s;
}
.btn-einladen:hover { border-color: #1e40af; }
.invite-box {
margin-top: 0.75rem; padding: 0.85rem;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;
display: flex; flex-direction: column; gap: 0.5rem;
}
.invite-label { font-size: 0.78rem; color: #64748b; margin: 0; }
.invite-url {
font-size: 0.78rem; font-family: monospace;
color: #1e293b; word-break: break-all;
}
.btn-kopieren {
padding: 0.4rem 0.75rem; background: #1e40af; color: #fff;
border: none; border-radius: 6px; font-size: 0.82rem;
cursor: pointer; align-self: flex-start;
}
.btn-logout {
width: 100%;
padding: 0.75rem;

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>

View file

@ -0,0 +1,92 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
// Users: +rolle, listRule für verein-weite Sichtbarkeit
{
const c = app.findCollectionByNameOrId("_pb_users_auth_")
c.fields.addAt(99, new Field({
"type": "select", "id": "select2001000070", "name": "rolle",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"maxSelect": 1, "values": ["admin", "trainer"]
}))
c.listRule = "@request.auth.verein_id = verein_id"
app.save(c)
}
// Gruppen: +trainer_ids (welche User betreuen diese Gruppe)
{
const c = app.findCollectionByNameOrId("pbc_3099069179")
c.fields.addAt(99, new Field({
"type": "relation", "id": "relation2001000071", "name": "trainer_ids",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"cascadeDelete": false, "collectionId": "_pb_users_auth_",
"maxSelect": 99, "minSelect": 0
}))
app.save(c)
}
// Einladungen-Collection
{
const c = new Collection({
"createRule": "@request.auth.verein_id = verein_id",
"deleteRule": "@request.auth.verein_id = verein_id",
"listRule": "@request.auth.verein_id = verein_id",
"viewRule": "",
"updateRule": "@request.auth.verein_id = verein_id",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}", "id": "text3208210256",
"max": 15, "min": 15, "name": "id", "pattern": "^[a-z0-9]+$",
"primaryKey": true, "required": true, "system": true, "type": "text",
"help": "", "hidden": false, "presentable": false
},
{
"type": "relation", "id": "relation2001000072", "name": "verein_id",
"help": "", "hidden": false, "presentable": false, "required": true, "system": false,
"cascadeDelete": true, "collectionId": "pbc_3589557411", "maxSelect": 1, "minSelect": 0
},
{
"type": "select", "id": "select2001000073", "name": "rolle",
"help": "", "hidden": false, "presentable": false, "required": true, "system": false,
"maxSelect": 1, "values": ["admin", "trainer"]
},
{
"type": "text", "id": "text2001000074", "name": "token",
"help": "", "hidden": false, "presentable": false, "required": true, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
},
{
"type": "bool", "id": "bool2001000075", "name": "genutzt",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false
}
],
"id": "pbc_einladungen",
"indexes": ["CREATE UNIQUE INDEX idx_einladungen_token ON einladungen (token)"],
"name": "einladungen",
"system": false,
"type": "base"
})
app.save(c)
}
}, (app) => {
{
const c = app.findCollectionByNameOrId("_pb_users_auth_")
c.fields.removeById("select2001000070")
c.listRule = ""
app.save(c)
}
{
const c = app.findCollectionByNameOrId("pbc_3099069179")
c.fields.removeById("relation2001000071")
app.save(c)
}
{
const c = app.findCollectionByNameOrId("pbc_einladungen")
app.delete(c)
}
})