Files
Harlekin abf42c5187
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Ranggefuege: Rollen, die aufeinander aufbauen
Bisher trug jede Rolle ihre Rechte vollstaendig selbst. Bei achtzehn Rollen
und zweiundzwanzig Rechten heisst das, dass eine hoehere Rangrolle die ganze
Liste der darunterliegenden noch einmal abhaken muss - beim Anlegen
fehleranfaellig und bei jeder spaeteren Aenderung doppelt zu pflegen.

Ein Gefuege ist eine benannte, geordnete Folge von Rollen. Jede Sprosse
erbt alles von jeder Sprosse unter ihr, verwaltet wird nur noch das, was
oben dazukommt. Geerbt wird nicht nur die Rechteliste, sondern auch
Freigabestufe und Anmeldung: sonst waere eine Befoerderung unter Umstaenden
eine Herabstufung, naemlich immer dann, wenn Discord beim Befoerdern die
alte Rangrolle abnimmt.

Die Ordnung ist bewusst eine Reihenfolge und kein Zeiger je Rolle - ueber
ganzen Zahlen gibt es keinen Zyklus, den man abfangen muesste. Aufloesen
eines Gefueges laesst die Rollen stehen und nimmt nur das Geerbte weg.

Die Rechnung steht als reine Funktion in lib/auth/ladders.ts und wird an
drei Stellen benutzt: bei der Rechteaufloesung, in der Verwaltung und in
der Vorschau des Editors, die im Browser mitrechnet. Eine Vorschau, die den
Stand vor dem Verschieben zeigt, waere schlimmer als keine.

Die Rollenseite zeigt Geerbtes kursiv und gesperrt an, damit dort niemand
blind Kaestchen setzt, die laengst gesetzt sind.

Nebenbei: Aenderungen an der Rollenzuordnung landen jetzt im Protokoll, mit
Vorher und Nachher. Wer Rollen aendert, aendert die Rechte anderer - genau
dafuer ist es da.
2026-09-04 14:53:29 +02:00

212 lines
7.2 KiB
TypeScript

'use client'
import { useState, useTransition } from 'react'
import { roleAdminText } from '@/lib/i18n/admin'
import { OPT_IN_PERMISSIONS, PERMISSIONS, type Permission } from '@/lib/auth/permissions'
import { saveRoleMapping } from './actions'
export type RoleRowData = {
discordRoleId: string
label: string
clearanceLevel: number
grantsMembership: boolean
category: string
permissions: string[]
isPresent: boolean
/** Rechte, die nur aus dem Ranggefüge kommen. Hier nicht abwählbar. */
inherited: string[]
ladderName: string | null
/** Stufe einschliesslich Gefüge - kann über der eigenen liegen. */
effectiveClearanceLevel: number
}
const CATEGORIES = ['MEMBERSHIP', 'RANK', 'CLEARANCE', 'UNSORTED'] as const
/**
* Eine Rolle. Bewusst nicht optimistisch: eine Änderung am Rollen-Mapping ist
* folgenreich und wirkt sofort für alle. Stattdessen Bestätigungsdialog,
* gesperrter Knopf mit wechselnder Beschriftung und eine klare Rückmeldung.
* Siehe docs/09-performance-und-ux.md.
*/
export function RoleRow({
role,
levels,
}: {
role: RoleRowData
levels: { level: number; name: string }[]
}) {
const [clearanceLevel, setClearanceLevel] = useState(role.clearanceLevel)
const [grantsMembership, setGrantsMembership] = useState(role.grantsMembership)
const [category, setCategory] = useState(role.category)
const [permissions, setPermissions] = useState<string[]>(role.permissions)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pending, startTransition] = useTransition()
const dirty =
clearanceLevel !== role.clearanceLevel ||
grantsMembership !== role.grantsMembership ||
category !== role.category ||
permissions.slice().sort().join() !== role.permissions.slice().sort().join()
function toggle(permission: Permission, on: boolean) {
setPermissions((current) =>
on ? [...current, permission] : current.filter((p) => p !== permission),
)
setSaved(false)
}
function submit() {
if (!confirm(roleAdminText.confirm)) return
setError(null)
startTransition(async () => {
const result = await saveRoleMapping({
discordRoleId: role.discordRoleId,
clearanceLevel,
grantsMembership,
category,
permissions,
})
if (result.ok) {
setSaved(true)
} else {
setError(result.error)
}
})
}
return (
<div className="border-b border-line py-5">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h3 className="font-condensed">{role.label}</h3>
<span className="font-mono text-2xs text-text-dim">{role.discordRoleId}</span>
{!role.isPresent ? (
<span className="font-condensed text-2xs text-stamp-4">
{roleAdminText.missingFromGuild}
</span>
) : null}
{role.ladderName ? (
<span className="font-condensed text-2xs text-text-dim">
{roleAdminText.inLadder} {role.ladderName}
</span>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-x-8 gap-y-3 text-xs">
<label className="field-row">
<span className="font-condensed text-text-dim">{roleAdminText.category}</span>
<select
value={category}
onChange={(event) => {
setCategory(event.target.value)
setSaved(false)
}}
className="field"
>
{CATEGORIES.map((value) => (
<option key={value} value={value}>
{roleAdminText.categories[value]}
</option>
))}
</select>
</label>
<label className="field-row">
<span className="font-condensed text-text-dim">{roleAdminText.clearance}</span>
<select
value={clearanceLevel}
onChange={(event) => {
setClearanceLevel(Number(event.target.value))
setSaved(false)
}}
className="field"
>
{levels.map((level) => (
<option key={level.level} value={level.level}>
{level.level} · {level.name}
</option>
))}
</select>
{role.effectiveClearanceLevel > clearanceLevel ? (
<span className="font-condensed text-2xs text-stamp-3">
{roleAdminText.clearanceRaised} {role.effectiveClearanceLevel}
</span>
) : null}
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={grantsMembership}
onChange={(event) => {
setGrantsMembership(event.target.checked)
setSaved(false)
}}
/>
<span className="font-condensed text-text-dim">{roleAdminText.grantsMembership}</span>
</label>
</div>
<fieldset className="mt-4">
<legend className="text-xs text-text-dim">
{roleAdminText.permissions}
{role.inherited.length > 0 ? (
<span className="ml-2 text-text-dim">{roleAdminText.inheritedNote}</span>
) : null}
</legend>
<div className="mt-2 grid grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] gap-x-4 gap-y-1.5">
{PERMISSIONS.map((permission) => {
const optIn = OPT_IN_PERMISSIONS.includes(permission)
// Geerbtes ist hier nicht abwählbar: das Kästchen gehört zur
// niedrigeren Sprosse. Es abzublenden wäre die schlechtere Antwort -
// dann sähe die Rolle so aus, als könnte sie weniger, als sie kann.
const geerbt = role.inherited.includes(permission)
return (
<label
key={permission}
className="flex items-center gap-2 font-mono text-2xs"
title={
geerbt
? `${roleAdminText.inheritedFrom} ${role.ladderName ?? ''}`.trim()
: optIn
? roleAdminText.optIn
: undefined
}
>
<input
type="checkbox"
checked={geerbt || permissions.includes(permission)}
disabled={geerbt}
onChange={(event) => toggle(permission, event.target.checked)}
/>
<span
className={
geerbt ? 'text-text-dim italic' : optIn ? 'text-stamp-3' : undefined
}
>
{permission}
</span>
</label>
)
})}
</div>
</fieldset>
<div className="mt-4 flex items-center gap-3">
<button
type="button"
onClick={submit}
disabled={pending || !dirty}
className="btn btn-primary"
>
{pending ? roleAdminText.saving : roleAdminText.save}
</button>
{saved && !dirty ? (
<span className="font-condensed text-2xs text-text-dim">{roleAdminText.saved}</span>
) : null}
{error ? <span className="font-condensed text-2xs text-stamp-4">{error}</span> : null}
</div>
</div>
)
}