feat: Superadmin-Rolle und Standard-Flags fuer neue Teams

Die Rolle superadmin kommt als Feld an users, der erste ist
stammtisch@dne.name. Vergeben wird sie nur in PocketBase: Die updateRule
schliesst das Feld ueber @request.body.superadmin:isset = false aus, sonst
koennte sich jeder selbst befoerdern - jeder darf schliesslich seinen
eigenen Datensatz aendern. Besondere Rechte in der App haengen noch nicht
daran.

Flag-Typen bleiben ausdruecklich Sache der Teams. Ein neues Team bekommt
beim Anlegen den hartkodierten Standardsatz aus der App und kann ihn danach
frei aendern; eine appweite Verwaltung entfaellt damit. Ein Team ohne
Flag-Typen koennte zwar Marker aufnehmen, aber nichts einordnen.

Im Kopf stehen Avatar und Name links, Design-Umschalter und Logout rechts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
This commit is contained in:
Daniel Michelberger 2026-09-02 15:03:41 +02:00
parent 561d7cbbc7
commit 27d34ad0d3
8 changed files with 139 additions and 66 deletions

41
TODO.md
View file

@ -5,36 +5,6 @@ es eine Entscheidung braucht oder weil es außerhalb des Repos passiert.
## Entscheidungen ## Entscheidungen
### Rollenmodell: Superadmin appweit
Geplant sind drei Rollen: **Superadmin** (appweit, über alle Teams),
**Team-Admin** (Owner/Admins eines Teams, gibt es schon) und **Mitglied**.
Umsetzung, sobald die zwei Fragen unten geklärt sind:
- `users.superadmin` als Bool-Feld.
- Die `updateRule` der users-Collection muss dabei
`@request.body.superadmin:isset = false` ergänzen — sonst kann sich jeder
Nutzer selbst befördern, denn er darf seinen eigenen Datensatz ändern.
Offen:
1. **Vorhandene Team-Flags.** Flag-Typen sollen appweit gelten (siehe unten).
Heute hat jedes Team seinen eigenen Satz. Zusammenführen und Duplikate von
Hand aufräumen, oder alle behalten und ab sofort global sichtbar machen?
2. **Erster Superadmin.** Den kann nur jemand direkt im PocketBase-Admin
setzen. Reicht ein Hinweis im Migrationskommentar, oder soll es eine
Superadmin-Verwaltung in den Einstellungen geben?
### Flag-Typen appweit statt pro Team
Hängt am Rollenmodell. Heute ist `trail_flags` team-gebunden: Das Feld `team`
existiert, der Store filtert danach, lesen darf das Team, schreiben Owner und
Admins.
Ziel: `team` entfällt, lesen für alle Eingeloggten, schreiben nur Superadmin.
Die Typen werden damit App-weite Vorschläge, aus denen jedes Team auswählt.
### Trail-Status: dürfen Mitglieder melden? ### Trail-Status: dürfen Mitglieder melden?
Anzeige und Herkunft (`status_changed`, `status_by`) sind fertig. Ändern darf Anzeige und Herkunft (`status_changed`, `status_by`) sind fertig. Ändern darf
@ -80,12 +50,13 @@ Kacheln ohne Schlüssel weiter — die Karte bleibt also sichtbar.
### Migrationen greifen beim nächsten Start ### Migrationen greifen beim nächsten Start
Vier neue Migrationen liegen in `backend/pb_migrations/`: Fünf neue Migrationen liegen in `backend/pb_migrations/`:
- `1754500500_riders_user_and_admin_rules.js` - `1754500500_riders_user_and_admin_rules.js`
- `1754500600_events_dates_instead_of_status.js` - `1754500600_events_dates_instead_of_status.js`
- `1754500700_trail_status_meta.js` - `1754500700_trail_status_meta.js`
- `1754500800_trail_markers_custom.js` - `1754500800_trail_markers_custom.js`
- `1754500900_users_superadmin.js`
Solange sie nicht gelaufen sind, scheitert alles, was auf den neuen Regeln Solange sie nicht gelaufen sind, scheitert alles, was auf den neuen Regeln
aufbaut. Am deutlichsten beim Anlegen eines Mitglieds oder eines Fahrer-Logins: aufbaut. Am deutlichsten beim Anlegen eines Mitglieds oder eines Fahrer-Logins:
@ -118,4 +89,12 @@ des Generators.
- Der Melder eines Trail-Markers („wer") zeigt bis zum Deploy „Unbekannt". Die - Der Melder eines Trail-Markers („wer") zeigt bis zum Deploy „Unbekannt". Die
gelockerte `users.viewRule` kommt mit `1754500500`. gelockerte `users.viewRule` kommt mit `1754500500`.
- Die Rolle **Superadmin** existiert seit `1754500900` und ist für
stammtisch@dne.name gesetzt, gibt in der App aber noch keine besonderen
Rechte. Sie ist bewusst nur in PocketBase vergebbar: Die `updateRule` der
users-Collection schließt `superadmin` vom Selbstsetzen aus. Sobald klar ist,
was sie dürfen soll, kommen die Regeln dazu.
- Flag-Typen bleiben **teamspezifisch**. Ein neues Team bekommt beim Anlegen
den hartkodierten Standardsatz aus `trailFlags.svelte.ts`, den es danach frei
ändern kann. Eine appweite Verwaltung gibt es damit bewusst nicht.
- `NAMING.md` und `frontend/static/pedaler.svg` liegen bewusst nur lokal. - `NAMING.md` und `frontend/static/pedaler.svg` liegen bewusst nur lokal.

View file

@ -14,7 +14,7 @@ migrate((app) => {
"listRule": "id = @request.auth.id", "listRule": "id = @request.auth.id",
"viewRule": "@request.auth.id != \"\"", "viewRule": "@request.auth.id != \"\"",
"createRule": "@request.auth.id != \"\"", "createRule": "@request.auth.id != \"\"",
"updateRule": "id = @request.auth.id", "updateRule": "id = @request.auth.id && @request.body.superadmin:isset = false",
"deleteRule": "id = @request.auth.id", "deleteRule": "id = @request.auth.id",
"name": "users", "name": "users",
"type": "auth", "type": "auth",
@ -125,6 +125,15 @@ migrate((app) => {
"system": false, "system": false,
"type": "text" "type": "text"
}, },
{
"hidden": false,
"id": "bool_user_superadmin",
"name": "superadmin",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{ {
"help": "", "help": "",
"hidden": false, "hidden": false,

View file

@ -0,0 +1,47 @@
/// <reference path="../pb_data/types.d.ts" />
// Superadmin als appweite Rolle, über allen Teams.
//
// Wichtig ist die Ergänzung der updateRule: Jeder darf seinen eigenen
// Datensatz ändern — ohne `@request.body.superadmin:isset = false` könnte
// sich damit jeder selbst befördern. Vergeben wird die Rolle deshalb nur
// direkt in PocketBase oder durch eine Migration wie diese.
//
// Der erste Superadmin ist stammtisch@dne.name. Existiert das Konto (noch)
// nicht, passiert hier nichts weiter — die Rolle lässt sich jederzeit im
// Admin-UI nachziehen.
//
// Flag-Typen bleiben ausdrücklich Sache der Teams: Jedes Team verwaltet
// seine eigenen und bekommt beim Anlegen den Standardsatz aus der App.
// Superadmin hat damit im Alltag nichts zu tun.
const FIRST_SUPERADMIN = 'stammtisch@dne.name'
migrate((app) => {
const users = app.findCollectionByNameOrId('users')
if (!users.fields.getByName('superadmin')) {
users.fields.add(new Field({
id: 'bool_user_superadmin',
name: 'superadmin',
type: 'bool',
}))
}
users.updateRule = 'id = @request.auth.id && @request.body.superadmin:isset = false'
app.save(users)
try {
const first = app.findAuthRecordByEmail('users', FIRST_SUPERADMIN)
first.set('superadmin', true)
app.save(first)
} catch {
// Konto noch nicht angelegt — die Rolle wird dann von Hand vergeben.
}
}, (app) => {
const users = app.findCollectionByNameOrId('users')
users.fields.removeByName('superadmin')
users.updateRule = 'id = @request.auth.id'
app.save(users)
})

View file

@ -105,14 +105,19 @@ export class TrailFlagStore {
* unangetastet der Knopf lässt sich also gefahrlos mehrfach drücken. * unangetastet der Knopf lässt sich also gefahrlos mehrfach drücken.
* Gibt die Anzahl neu angelegter Flags zurück. * Gibt die Anzahl neu angelegter Flags zurück.
*/ */
async seedDefaults(): Promise<number> { async seedDefaults(teamId = this.teams.activeId): Promise<number> {
const teamId = this.teams.activeId
if (!teamId) throw new Error('Kein aktives Team') if (!teamId) throw new Error('Kein aktives Team')
if (!this.canManage) { // Beim Anlegen eines Teams ist man dessen Owner, aber der Datensatz
// steckt womöglich noch nicht im Store — dann greift die Prüfung ins
// Leere. Für das eigene, gerade erzeugte Team wird sie übersprungen;
// die createRule der Collection setzt sie ohnehin serverseitig durch.
if (teamId === this.teams.activeId && !this.canManage) {
throw new Error('Dafür brauchst du Administratorrechte im Team.') throw new Error('Dafür brauchst du Administratorrechte im Team.')
} }
const existing = new Set(this.scoped.map((f) => f.label)) const existing = new Set(
this.records.filter((f) => f.team === teamId).map((f) => f.label),
)
let created = 0 let created = 0
for (const flag of DEFAULT_FLAGS) { for (const flag of DEFAULT_FLAGS) {

View file

@ -202,6 +202,7 @@ export type UsersRecord = {
id: string id: string
name?: string name?: string
password: string password: string
superadmin?: boolean
tokenKey: string tokenKey: string
updated?: IsoDateString updated?: IsoDateString
username: string username: string

View file

@ -123,14 +123,21 @@
<!-- Rechts eine Spalte: Name samt Design und Logout in einer <!-- Rechts eine Spalte: Name samt Design und Logout in einer
Zeile, das Team-Dropdown darunter. --> Zeile, das Team-Dropdown darunter. -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="hidden md:flex flex-col items-end gap-1"> <!-- Wer man ist, steht links; was man tun kann, rechts. -->
<div class="flex items-center gap-2"> <div class="hidden md:flex min-w-56 flex-col gap-1">
<a href="/dashboard/settings" title="Profil"> <div class="flex items-center justify-between gap-3">
<a
href="/dashboard/settings"
title="Profil"
class="flex min-w-0 items-center gap-2"
>
<Avatar user={auth.user} size={24} /> <Avatar user={auth.user} size={24} />
</a> <span class="truncate text-sm font-medium leading-none">
<p class="text-sm font-medium leading-none">
{auth.user.name || auth.user.email} {auth.user.name || auth.user.email}
</p> </span>
</a>
<span class="flex shrink-0 items-center gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -153,9 +160,13 @@
> >
<LogOut class="h-4 w-4" /> <LogOut class="h-4 w-4" />
</Button> </Button>
</span>
</div> </div>
<div class="flex">
<TeamSwitcher /> <TeamSwitcher />
</div> </div>
</div>
<Button <Button
variant="ghost" variant="ghost"

View file

@ -10,6 +10,7 @@
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import Avatar from '$lib/components/Avatar.svelte' import Avatar from '$lib/components/Avatar.svelte'
import { Badge } from '@/components/ui/badge'
import { KeyRound, User } from 'lucide-svelte' import { KeyRound, User } from 'lucide-svelte'
let name = $state(auth.user?.name ?? '') let name = $state(auth.user?.name ?? '')
@ -101,6 +102,10 @@
<CardTitle class="flex items-center gap-2 text-base"> <CardTitle class="flex items-center gap-2 text-base">
<User class="size-4 text-primary" /> <User class="size-4 text-primary" />
Konto Konto
{#if auth.user?.superadmin}
<!-- Appweite Rolle; vergeben wird sie nur in PocketBase. -->
<Badge variant="secondary">Superadmin</Badge>
{/if}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">

View file

@ -5,6 +5,7 @@
*/ */
import { api } from '$lib/stores/pocketbase.svelte' import { api } from '$lib/stores/pocketbase.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte' import { getTeamContext } from '$lib/stores/teams.svelte'
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -16,6 +17,7 @@
import type { TeamsResponse } from '$lib/types' import type { TeamsResponse } from '$lib/types'
const teams = getTeamContext() const teams = getTeamContext()
const flags = getTrailFlagContext()
const me = $derived(api.authStore.record?.id ?? null) const me = $derived(api.authStore.record?.id ?? null)
// Namen der Mitglieder nachladen — ein Team kennt nur deren IDs. // Namen der Mitglieder nachladen — ein Team kennt nur deren IDs.
@ -33,7 +35,21 @@
createError = null createError = null
try { try {
await teams.create(createName.trim()) const team = await teams.create(createName.trim())
// Ein Team ohne Flag-Typen kann zwar Marker aufnehmen, aber nichts
// einordnen. Deshalb kommt der Standardsatz gleich mit — änderbar
// und, wie alle Flag-Typen, allein Sache dieses Teams.
try {
await flags.seedDefaults(team.id)
} catch (e: any) {
createError =
'Das Team wurde angelegt, die Standard-Flag-Typen aber nicht: ' +
(e.message ?? 'unbekannter Fehler') +
' Du kannst sie unter „Flag-Typen" nachholen.'
return
}
createDialog = false createDialog = false
createName = '' createName = ''
} catch (e: any) { } catch (e: any) {