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
|
||||
|
||||
### 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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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.
|
||||
* 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) {
|
||||
|
|
|
|||
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
|
||||
name?: string
|
||||
password: string
|
||||
superadmin?: boolean
|
||||
tokenKey: string
|
||||
updated?: IsoDateString
|
||||
username: string
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue