Rollen: Trainer-Einladung, rollenbasierte Navigation und Zugriffskontrolle
This commit is contained in:
parent
7e2e5a643d
commit
59aa3cbcce
6 changed files with 413 additions and 9 deletions
|
|
@ -67,14 +67,18 @@
|
|||
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
const navItems: { href: string; label: string; icon: IconName }[] = [
|
||||
{ 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: '/nachrichten', label: 'Nachrichten', icon: 'envelope' },
|
||||
{ href: '/einstellungen', label: 'Einstellungen', icon: 'gear' },
|
||||
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', 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">
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue