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:
parent
561d7cbbc7
commit
27d34ad0d3
8 changed files with 139 additions and 66 deletions
41
TODO.md
41
TODO.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
47
backend/pb_migrations/1754500900_users_superadmin.js
Normal file
47
backend/pb_migrations/1754500900_users_superadmin.js
Normal 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)
|
||||||
|
})
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
1
frontend/src/lib/types.d.ts
vendored
1
frontend/src/lib/types.d.ts
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue