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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
dbca57cedf
commit
ea389c12fa
@@ -0,0 +1,68 @@
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
|
||||
|
||||
type Level = { level: number; name: string; hiddenBelow: boolean }
|
||||
|
||||
/**
|
||||
* „Was sieht Stufe 2?" aus TASKS.md Phase 1.
|
||||
*
|
||||
* Beantwortet die Frage, die der Abteilungsleitung beim Vergeben von Rollen
|
||||
* tatsächlich im Kopf herumgeht: wer kommt auf welche Stufe, und was heisst
|
||||
* das für alles darüber. Rein serverseitig gerechnet, nichts Interaktives.
|
||||
*/
|
||||
export function ClearancePreview({
|
||||
levels,
|
||||
mappings,
|
||||
}: {
|
||||
levels: Level[]
|
||||
mappings: { label: string; clearanceLevel: number }[]
|
||||
}) {
|
||||
return (
|
||||
<section className="mt-8 border border-line">
|
||||
<div className="border-b border-line px-4 py-2">
|
||||
<h2 className="font-condensed">{de.roleAdmin.preview.heading}</h2>
|
||||
<p className="mt-1 text-xs text-text-dim">{de.roleAdmin.preview.intro}</p>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{levels.map((level) => {
|
||||
const viaRoles = mappings.filter((m) => m.clearanceLevel === level.level)
|
||||
// Eine Akte über der Stufe des Lesers ist entweder unsichtbar oder
|
||||
// sichtbar aber gesperrt. Das steuert ClearanceLevelConfig.hiddenBelow
|
||||
// der jeweils höheren Stufe.
|
||||
const higher = levels.filter((l) => l.level > level.level)
|
||||
const anyHidden = higher.some((l) => l.hiddenBelow)
|
||||
|
||||
return (
|
||||
<li
|
||||
key={level.level}
|
||||
className="border-b border-line/60 px-4 py-3 last:border-b-0"
|
||||
style={classificationEdgeStyle(level.level)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<StampMark level={level.level} name={level.name} />
|
||||
<span className="text-xs text-text-dim">
|
||||
{de.roleAdmin.preview.sees} {level.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-1.5 text-xs text-text-dim">
|
||||
{higher.length === 0
|
||||
? null
|
||||
: anyHidden
|
||||
? de.roleAdmin.preview.hidden
|
||||
: de.roleAdmin.preview.lockedOnly}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-2xs text-text-dim">
|
||||
{viaRoles.length === 0
|
||||
? de.roleAdmin.preview.nobody
|
||||
: `${de.roleAdmin.preview.viaRoles}: ${viaRoles.map((r) => r.label).join(', ')}`}
|
||||
</p>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { requirePermissionForAction } from '@/lib/auth/current'
|
||||
import { PERMISSIONS } from '@/lib/auth/permissions'
|
||||
import { invalidateAll } from '@/lib/auth/effective'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
|
||||
const schema = z.object({
|
||||
discordRoleId: z.string().min(1),
|
||||
clearanceLevel: z.coerce.number().int().min(0).max(5),
|
||||
grantsMembership: z.boolean(),
|
||||
category: z.enum(['MEMBERSHIP', 'RANK', 'CLEARANCE', 'UNSORTED']),
|
||||
permissions: z.array(z.enum(PERMISSIONS)),
|
||||
})
|
||||
|
||||
export type SaveResult = { ok: true } | { ok: false; error: string }
|
||||
|
||||
/**
|
||||
* Speichert die Zuordnung einer Discord-Rolle.
|
||||
*
|
||||
* Die Prüfung findet hier statt, nicht in der Oberfläche: eine Server Action
|
||||
* ist ein Endpunkt und wird direkt aufrufbar, egal was das UI versteckt.
|
||||
*
|
||||
* Der Trigger `role_mapping_notify` löst danach `pg_notify` aus, worauf jeder
|
||||
* Prozess seinen Rechte-Cache verwirft. `invalidateAll` deckt zusätzlich den
|
||||
* eigenen Prozess sofort ab, ohne auf die Runde über die Datenbank zu warten.
|
||||
*/
|
||||
export async function saveRoleMapping(input: unknown): Promise<SaveResult> {
|
||||
await requirePermissionForAction('roles.manage')
|
||||
|
||||
const parsed = schema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: de.roleAdmin.invalidInput }
|
||||
}
|
||||
|
||||
const { discordRoleId, clearanceLevel, grantsMembership, category, permissions } = parsed.data
|
||||
|
||||
const existing = await db.roleMapping.findUnique({ where: { discordRoleId } })
|
||||
if (!existing) return { ok: false, error: de.roleAdmin.roleGone }
|
||||
|
||||
await db.roleMapping.update({
|
||||
where: { discordRoleId },
|
||||
data: {
|
||||
clearanceLevel,
|
||||
grantsMembership,
|
||||
category,
|
||||
permissions: [...new Set(permissions)].sort(),
|
||||
},
|
||||
})
|
||||
|
||||
invalidateAll()
|
||||
revalidatePath('/admin/rollen')
|
||||
revalidatePath('/uebersicht')
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { db } from '@/lib/db'
|
||||
import { requirePermission } from '@/lib/auth/current'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { RoleRow } from './RoleRow'
|
||||
import { ClearancePreview } from './ClearancePreview'
|
||||
|
||||
export const metadata: Metadata = { title: de.roleAdmin.heading }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const ORDER = ['MEMBERSHIP', 'CLEARANCE', 'RANK', 'UNSORTED'] as const
|
||||
|
||||
export default async function RoleAdminPage() {
|
||||
// Ohne roles.manage gibt es 404, nicht 403.
|
||||
await requirePermission('roles.manage')
|
||||
|
||||
const [mappings, levels] = await Promise.all([
|
||||
db.roleMapping.findMany({ orderBy: [{ sortOrder: 'desc' }, { label: 'asc' }] }),
|
||||
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
|
||||
])
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-10">
|
||||
<h1 className="font-condensed">{de.roleAdmin.heading}</h1>
|
||||
<p className="mt-2 max-w-(--container-measure) text-text-dim">{de.roleAdmin.intro}</p>
|
||||
|
||||
{mappings.length === 0 ? (
|
||||
<p className="mt-8 text-text-dim">{de.roleAdmin.empty}</p>
|
||||
) : (
|
||||
<>
|
||||
<ClearancePreview
|
||||
levels={levels}
|
||||
mappings={mappings.map((m) => ({
|
||||
label: m.label,
|
||||
clearanceLevel: m.clearanceLevel,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{ORDER.map((category) => {
|
||||
const rows = mappings.filter((m) => m.category === category)
|
||||
if (rows.length === 0) return null
|
||||
return (
|
||||
<section key={category} className="mt-10">
|
||||
<h2 className="font-condensed text-text-dim">
|
||||
{de.roleAdmin.categories[category]}
|
||||
</h2>
|
||||
<div className="mt-2 border-t border-line">
|
||||
{rows.map((role) => (
|
||||
<RoleRow
|
||||
key={role.discordRoleId}
|
||||
role={{
|
||||
discordRoleId: role.discordRoleId,
|
||||
label: role.label,
|
||||
clearanceLevel: role.clearanceLevel,
|
||||
grantsMembership: role.grantsMembership,
|
||||
category: role.category,
|
||||
permissions: role.permissions,
|
||||
isPresent: role.isPresent,
|
||||
}}
|
||||
levels={levels.map((l) => ({ level: l.level, name: l.name }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Suspense } from 'react'
|
||||
import { requirePermission } from '@/lib/auth/current'
|
||||
import { collectHealth, type ComponentState } from '@/lib/health/check'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { SkeletonLine } from '@/components/Skeleton'
|
||||
|
||||
export const metadata: Metadata = { title: de.health.heading }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const STATE_LABEL: Record<ComponentState, string> = {
|
||||
up: de.health.ok,
|
||||
degraded: de.health.degraded,
|
||||
down: de.health.down,
|
||||
}
|
||||
|
||||
async function SystemState() {
|
||||
const report = await collectHealth()
|
||||
return (
|
||||
<table className="w-full max-w-(--container-measure) border-collapse text-sm">
|
||||
<caption className="pb-2 text-left font-condensed text-xs text-text-dim">
|
||||
{de.health.checkedAt(new Date(report.checkedAt).toLocaleString('de-DE'))}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-line text-left text-xs text-text-dim">
|
||||
<th scope="col" className="py-1.5 pr-4 font-normal">
|
||||
{de.health.service}
|
||||
</th>
|
||||
<th scope="col" className="py-1.5 font-normal">
|
||||
{de.health.state}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(report.components).map(([key, value]) => (
|
||||
<tr key={key} className="border-b border-line/60">
|
||||
<td className="py-2 pr-4">{de.health.components[key] ?? key}</td>
|
||||
<td className="py-2">
|
||||
{/* Zustand als Text, nicht nur als Farbe. */}
|
||||
<span className="font-condensed">{STATE_LABEL[value.status]}</span>
|
||||
{value.detail ? (
|
||||
<span className="ml-2 font-mono text-2xs text-text-dim">{value.detail}</span>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function SystemPage() {
|
||||
await requirePermission('admin')
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-(--container-measure) px-6 py-10">
|
||||
<h1 className="font-condensed">{de.health.heading}</h1>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="mt-6">
|
||||
<SkeletonLine width="14rem" />
|
||||
<SkeletonLine />
|
||||
<SkeletonLine />
|
||||
<SkeletonLine />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="mt-6">
|
||||
<SystemState />
|
||||
</div>
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Link from 'next/link'
|
||||
import { signOut } from '@/auth'
|
||||
import { requireUser } from '@/lib/auth/current'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await requireUser()
|
||||
|
||||
async function abmelden() {
|
||||
'use server'
|
||||
await signOut({ redirectTo: '/anmelden' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh">
|
||||
<header className="border-b border-line">
|
||||
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-6 gap-y-2 px-6 py-3">
|
||||
<Link href="/uebersicht" className="font-condensed text-text no-underline">
|
||||
{de.app.shortName}
|
||||
</Link>
|
||||
|
||||
<nav className="flex gap-4 font-condensed text-xs">
|
||||
<Link href="/uebersicht">{de.nav.dashboard}</Link>
|
||||
{user.permissions.isAdmin || user.permissions.permissions.has('roles.manage') ? (
|
||||
<Link href="/admin/rollen">{de.nav.admin}</Link>
|
||||
) : null}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3 text-xs text-text-dim">
|
||||
<span className="font-mono">{user.displayName}</span>
|
||||
<form action={abmelden}>
|
||||
<button type="submit" className="font-condensed text-accent">
|
||||
{de.auth.signOut}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="inhalt">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { db } from '@/lib/db'
|
||||
import { requireUser } from '@/lib/auth/current'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { PERMISSIONS } from '@/lib/auth/permissions'
|
||||
import { StampMark } from '@/components/StampMark'
|
||||
|
||||
export const metadata: Metadata = { title: de.overview.heading }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Zwischenstand aus TASKS.md Phase 1: zeigt Name, Rollen, errechnete Stufe und
|
||||
* Rechte. Das ist die Seite, an der die Abteilungsleitung nachvollziehen kann,
|
||||
* warum jemand etwas sieht oder eben nicht.
|
||||
*/
|
||||
export default async function OverviewPage() {
|
||||
const user = await requireUser()
|
||||
|
||||
const [roles, level] = await Promise.all([
|
||||
db.userRole.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { discordRoleId: true },
|
||||
}),
|
||||
db.clearanceLevelConfig.findUnique({ where: { level: user.permissions.clearanceLevel } }),
|
||||
])
|
||||
|
||||
const mappings = await db.roleMapping.findMany({
|
||||
where: { discordRoleId: { in: roles.map((r) => r.discordRoleId) } },
|
||||
orderBy: [{ category: 'asc' }, { sortOrder: 'desc' }],
|
||||
})
|
||||
const byId = new Map(mappings.map((m) => [m.discordRoleId, m]))
|
||||
|
||||
const granted = [...user.permissions.permissions].sort()
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-(--container-measure) px-6 py-10">
|
||||
<h1 className="font-condensed">{de.overview.heading}</h1>
|
||||
<p className="mt-2 text-text-dim">{de.overview.intro}</p>
|
||||
|
||||
<dl className="mt-8 grid grid-cols-[auto_1fr] gap-x-6 gap-y-3 text-sm">
|
||||
<dt className="font-condensed text-text-dim">{de.overview.person}</dt>
|
||||
<dd>{user.displayName}</dd>
|
||||
|
||||
<dt className="font-condensed text-text-dim">{de.overview.discordId}</dt>
|
||||
<dd className="font-mono text-xs">{user.id}</dd>
|
||||
|
||||
<dt className="font-condensed text-text-dim">{de.overview.clearance}</dt>
|
||||
<dd>
|
||||
<StampMark
|
||||
level={user.permissions.clearanceLevel}
|
||||
name={level?.name ?? de.common.unknown}
|
||||
/>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{user.permissions.isAdmin ? (
|
||||
<p className="mt-6 border-l-(length:--spacing-edge) border-stamp-5 pl-3 text-xs text-text-dim">
|
||||
{de.overview.bootstrapAdmin}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<h2 className="mt-10 font-condensed">{de.overview.roles}</h2>
|
||||
{roles.length === 0 ? (
|
||||
<p className="mt-2 text-text-dim">{de.overview.noRoles}</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-1.5 text-sm">
|
||||
{roles.map((role) => {
|
||||
const mapping = byId.get(role.discordRoleId)
|
||||
return (
|
||||
<li key={role.discordRoleId} className="flex flex-wrap items-baseline gap-2">
|
||||
<span>{mapping?.label ?? role.discordRoleId}</span>
|
||||
<span className="font-condensed text-2xs text-text-dim">
|
||||
{mapping
|
||||
? `${de.roleAdmin.categories[mapping.category]} · ${de.clearance.level(mapping.clearanceLevel)}`
|
||||
: de.overview.unmapped}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<h2 className="mt-10 font-condensed">{de.overview.permissions}</h2>
|
||||
{granted.length === 0 ? (
|
||||
<p className="mt-2 text-text-dim">{de.overview.noPermissions}</p>
|
||||
) : (
|
||||
<ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5 font-mono text-xs">
|
||||
{PERMISSIONS.filter((p) => granted.includes(p)).map((permission) => (
|
||||
<li key={permission}>{permission}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user