Files
AfiS-Aktensystem/web/app/(app)/admin/rollen/RoleRow.tsx
T
Harlekin cb66839a05 Phase 1: Anmeldung, Rechte und Rollenverwaltung
Auth.js übernimmt nur den OAuth-Ablauf mit state und PKCE, Scope identify. Das
Zugangstoken des Nutzers wird danach verworfen: die Rollen holt der Bot mit
seinem eigenen Token. Die Sitzung steht in der Datenbank, im Cookie liegt nur
userId und sessionId. Ohne die Zeile in Session ist das Cookie wertlos, und nur
so lässt sich eine Sitzung zurückziehen.

Wer sich anmelden darf, steckt in lib/auth/signInPolicy.ts statt im Callback:
das ist die Regel, an der alles hängt, und sie gehört einzeln prüfbar. Kein
Guild-Mitglied, kein Zugang. Keine Rolle mit grantsMembership, kein Zugang.
Notfall-Admins umgehen die Rolle, aber nicht die Guild-Mitgliedschaft.

RoleMapping trägt jetzt grantsMembership, category und isPresent. Der Worker
spiegelt jede Rolle der Guild hierher, damit im Adminbereich nichts von Hand
eingetragen werden muss. Gelöschte Rollen werden nicht entfernt, sondern auf
isPresent = false gesetzt: beim Umzug auf den echten Server bleiben sie als
Vorlage stehen.

Der Worker gleicht über Gateway-Ereignisse und zusätzlich alle fünf Minuten
voll ab. Verliert jemand seine AfiS-Rolle, beendet er dessen Sitzungen sofort
und setzt isActive auf false — der Datensatz bleibt, damit Autorenangaben in
alten Akten gültig bleiben. Das passiert im Worker und nicht in der Web-App,
weil es dort erst beim nächsten Request des Betroffenen griffe.

Zwei Widersprüche der Dokumente aufgelöst, festgehalten in
docs/13-entscheidungen.md: der Ausschluss schlägt auch den Admin (das
Codebeispiel in 03-auth-und-rechte.md übergeht ihn, die Reihenfolge eine Zeile
darüber nicht), und versiegelte Akten fallen im Where-Fragment heraus statt in
einer Prüfung danach.

45 Tests, davon die Negativfälle aus docs/03 gegen eine echte Datenbank: Stufe 2
sieht die Stufe-4-Akte nicht und findet sie auch nicht in der Trefferzahl, ein
Ausgeschlossener mit Stufe 5 kommt nicht heran, ein Rollenentzug beendet die
Sitzungen, und ein Ausfall der Discord-API sieht nicht aus wie eine verweigerte
Mitgliedschaft.
2026-09-01 19:11:53 +02:00

175 lines
5.8 KiB
TypeScript

'use client'
import { useState, useTransition } from 'react'
import { de } from '@/lib/i18n/de'
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
}
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(de.roleAdmin.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">
{de.roleAdmin.missingFromGuild}
</span>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-x-8 gap-y-3 text-xs">
<label className="flex items-center gap-2">
<span className="font-condensed text-text-dim">{de.roleAdmin.category}</span>
<select
value={category}
onChange={(event) => {
setCategory(event.target.value)
setSaved(false)
}}
className="border border-line bg-raised px-2 py-1"
>
{CATEGORIES.map((value) => (
<option key={value} value={value}>
{de.roleAdmin.categories[value]}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="font-condensed text-text-dim">{de.roleAdmin.clearance}</span>
<select
value={clearanceLevel}
onChange={(event) => {
setClearanceLevel(Number(event.target.value))
setSaved(false)
}}
className="border border-line bg-raised px-2 py-1"
>
{levels.map((level) => (
<option key={level.level} value={level.level}>
{level.level} · {level.name}
</option>
))}
</select>
</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">{de.roleAdmin.grantsMembership}</span>
</label>
</div>
<fieldset className="mt-4">
<legend className="text-xs text-text-dim">{de.roleAdmin.permissions}</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)
return (
<label
key={permission}
className="flex items-center gap-2 font-mono text-2xs"
title={optIn ? de.roleAdmin.optIn : undefined}
>
<input
type="checkbox"
checked={permissions.includes(permission)}
onChange={(event) => toggle(permission, event.target.checked)}
/>
<span className={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="border border-line bg-raised px-3 py-1.5 font-condensed text-xs text-accent disabled:text-text-dim"
>
{pending ? de.roleAdmin.saving : de.roleAdmin.save}
</button>
{saved && !dirty ? (
<span className="font-condensed text-2xs text-text-dim">{de.roleAdmin.saved}</span>
) : null}
{error ? <span className="font-condensed text-2xs text-stamp-4">{error}</span> : null}
</div>
</div>
)
}