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
### 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?
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
Vier neue Migrationen liegen in `backend/pb_migrations/`:
Fünf neue Migrationen liegen in `backend/pb_migrations/`:
- `1754500500_riders_user_and_admin_rules.js`
- `1754500600_events_dates_instead_of_status.js`
- `1754500700_trail_status_meta.js`
- `1754500800_trail_markers_custom.js`
- `1754500900_users_superadmin.js`
Solange sie nicht gelaufen sind, scheitert alles, was auf den neuen Regeln
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
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.

View file

@ -14,7 +14,7 @@ migrate((app) => {
"listRule": "id = @request.auth.id",
"viewRule": "@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",
"name": "users",
"type": "auth",
@ -125,6 +125,15 @@ migrate((app) => {
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "bool_user_superadmin",
"name": "superadmin",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"help": "",
"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.
* Gibt die Anzahl neu angelegter Flags zurück.
*/
async seedDefaults(): Promise<number> {
const teamId = this.teams.activeId
async seedDefaults(teamId = this.teams.activeId): Promise<number> {
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.')
}
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
for (const flag of DEFAULT_FLAGS) {

View file

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

View file

@ -123,38 +123,49 @@
<!-- Rechts eine Spalte: Name samt Design und Logout in einer
Zeile, das Team-Dropdown darunter. -->
<div class="flex items-center gap-2">
<div class="hidden md:flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<a href="/dashboard/settings" title="Profil">
<!-- Wer man ist, steht links; was man tun kann, rechts. -->
<div class="hidden md:flex min-w-56 flex-col gap-1">
<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} />
<span class="truncate text-sm font-medium leading-none">
{auth.user.name || auth.user.email}
</span>
</a>
<p class="text-sm font-medium leading-none">
{auth.user.name || auth.user.email}
</p>
<Button
variant="ghost"
size="icon"
class="size-7"
onclick={toggleMode}
title="Design wechseln"
>
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
{/if}
</Button>
<Button
variant="ghost"
size="icon"
class="size-7"
onclick={handleLogout}
title="Abmelden"
>
<LogOut class="h-4 w-4" />
</Button>
<span class="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
class="size-7"
onclick={toggleMode}
title="Design wechseln"
>
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
{/if}
</Button>
<Button
variant="ghost"
size="icon"
class="size-7"
onclick={handleLogout}
title="Abmelden"
>
<LogOut class="h-4 w-4" />
</Button>
</span>
</div>
<div class="flex">
<TeamSwitcher />
</div>
<TeamSwitcher />
</div>
<Button

View file

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

View file

@ -5,6 +5,7 @@
*/
import { api } from '$lib/stores/pocketbase.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte'
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@ -16,6 +17,7 @@
import type { TeamsResponse } from '$lib/types'
const teams = getTeamContext()
const flags = getTrailFlagContext()
const me = $derived(api.authStore.record?.id ?? null)
// Namen der Mitglieder nachladen — ein Team kennt nur deren IDs.
@ -33,7 +35,21 @@
createError = null
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
createName = ''
} catch (e: any) {