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.
This commit is contained in:
@@ -22,6 +22,11 @@ AUTH_SECRET=hier-ein-langes-zufaelliges-geheimnis-mit-mindestens-32-zeichen
|
|||||||
# --- Discord ------------------------------------------------------------------
|
# --- Discord ------------------------------------------------------------------
|
||||||
# Aus dem Developer Portal. Das Server Members Intent muss aktiviert sein,
|
# Aus dem Developer Portal. Das Server Members Intent muss aktiviert sein,
|
||||||
# sonst liefert der Bot keine Rollen. Siehe docs/03-auth-und-rechte.md.
|
# sonst liefert der Bot keine Rollen. Siehe docs/03-auth-und-rechte.md.
|
||||||
|
# Als Redirect-URI in der Application eintragen:
|
||||||
|
# <APP_URL>/api/auth/callback/discord
|
||||||
|
# Der Bot muss ausserdem auf den Server eingeladen sein, sonst liefert die
|
||||||
|
# API "Unknown Guild":
|
||||||
|
# https://discord.com/oauth2/authorize?client_id=<DISCORD_CLIENT_ID>&scope=bot&permissions=3072
|
||||||
DISCORD_CLIENT_ID=
|
DISCORD_CLIENT_ID=
|
||||||
DISCORD_CLIENT_SECRET=
|
DISCORD_CLIENT_SECRET=
|
||||||
DISCORD_BOT_TOKEN=
|
DISCORD_BOT_TOKEN=
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RoleCategory" AS ENUM ('MEMBERSHIP', 'RANK', 'CLEARANCE', 'UNSORTED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "RoleMapping" ADD COLUMN "category" "RoleCategory" NOT NULL DEFAULT 'UNSORTED',
|
||||||
|
ADD COLUMN "grantsMembership" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "isPresent" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RoleMapping_category_sortOrder_idx" ON "RoleMapping"("category", "sortOrder");
|
||||||
@@ -72,13 +72,45 @@ model UserRole {
|
|||||||
|
|
||||||
/// Verbindet eine Discord-Rolle mit Freigabestufe und Rechten.
|
/// Verbindet eine Discord-Rolle mit Freigabestufe und Rechten.
|
||||||
/// Gepflegt im Adminbereich, siehe docs/03-auth-und-rechte.md.
|
/// Gepflegt im Adminbereich, siehe docs/03-auth-und-rechte.md.
|
||||||
|
///
|
||||||
|
/// Der Worker spiegelt jede Rolle der Guild hierher, damit im Adminbereich
|
||||||
|
/// nichts von Hand eingetragen werden muss. Neue Rollen stehen zunaechst auf
|
||||||
|
/// Stufe 0, ohne Rechte und ohne Zugang.
|
||||||
model RoleMapping {
|
model RoleMapping {
|
||||||
discordRoleId String @id
|
discordRoleId String @id
|
||||||
label String // Anzeigename, aus Discord gespiegelt
|
label String // Anzeigename, aus Discord gespiegelt
|
||||||
clearanceLevel Int @default(0)
|
clearanceLevel Int @default(0)
|
||||||
permissions String[]
|
permissions String[]
|
||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
|
|
||||||
|
/// Tuersteher: nur mit einer solchen Rolle ist eine Anmeldung moeglich.
|
||||||
|
/// Auf dem AfiS-Discord sind das "AfiS" und "Personalabteilung"; Gaeste auf
|
||||||
|
/// dem Server haben sie nicht. Siehe docs/13-entscheidungen.md.
|
||||||
|
grantsMembership Boolean @default(false)
|
||||||
|
|
||||||
|
/// Nur fuer die Gruppierung im Adminbereich. Achtzehn Rollen in einer Liste
|
||||||
|
/// sind unbrauchbar, drei benannte Abschnitte nicht.
|
||||||
|
category RoleCategory @default(UNSORTED)
|
||||||
|
|
||||||
|
/// Existiert die Rolle auf der Guild noch? Beim Umzug auf einen anderen
|
||||||
|
/// Server bleiben die alten Zuordnungen als Vorlage stehen, statt sie zu
|
||||||
|
/// loeschen: die Adminseite schlaegt sie anhand des Namens neu zu.
|
||||||
|
isPresent Boolean @default(true)
|
||||||
|
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([category, sortOrder])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RoleCategory {
|
||||||
|
/// "AfiS", "Personalabteilung" — regelt, wer sich anmelden darf
|
||||||
|
MEMBERSHIP
|
||||||
|
/// Dienstgrade, regeln die Rechte
|
||||||
|
RANK
|
||||||
|
/// Zugangsrollen, regeln die Freigabestufe
|
||||||
|
CLEARANCE
|
||||||
|
/// Noch nicht eingeordnet
|
||||||
|
UNSORTED
|
||||||
}
|
}
|
||||||
|
|
||||||
model ClearanceLevelConfig {
|
model ClearanceLevelConfig {
|
||||||
@@ -160,6 +192,11 @@ model Case {
|
|||||||
@@index([classification])
|
@@index([classification])
|
||||||
@@index([dueAt])
|
@@index([dueAt])
|
||||||
@@index([createdById])
|
@@index([createdById])
|
||||||
|
// Diese beiden werden in der Migration von Hand erzeugt, muessen Prisma aber
|
||||||
|
// bekannt sein: sonst hält Prisma sie fuer Drift und loescht sie bei der
|
||||||
|
// naechsten Migration. Siehe docs/13-entscheidungen.md.
|
||||||
|
@@index([searchVector], map: "case_search_idx", type: Gin)
|
||||||
|
@@index([caseNumber(ops: raw("gin_trgm_ops"))], map: "case_number_trgm_idx", type: Gin)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CaseStatus {
|
enum CaseStatus {
|
||||||
@@ -376,6 +413,8 @@ model PersonnelFile {
|
|||||||
@@index([displayName])
|
@@index([displayName])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([classification])
|
@@index([classification])
|
||||||
|
@@index([searchVector], map: "personnel_search_idx", type: Gin)
|
||||||
|
@@index([displayName(ops: raw("gin_trgm_ops"))], map: "personnel_name_trgm_idx", type: Gin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -44,9 +44,19 @@ suite('Auslieferungszustand', () => {
|
|||||||
expect(setting?.entryEditMarkGrace).toBe(false)
|
expect(setting?.entryEditMarkGrace).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ordnet im Auslieferungszustand keiner Rolle Rechte zu', async () => {
|
it('legt selbst keine Rollen-Zuordnung an', async () => {
|
||||||
// entry.edit_own, entry.edit_any und entry.retract schaltet der Leiter
|
// Der Seed vergibt keine Rechte. Rollen kommen ausschliesslich aus dem
|
||||||
// bewusst frei. Siehe docs/03-auth-und-rechte.md.
|
// Abgleich des Workers und werden im Adminbereich zugeordnet.
|
||||||
expect(await db.roleMapping.count()).toBe(0)
|
// Geprüft wird deshalb, dass der Seed nichts hinterlässt, das Rechte trägt
|
||||||
|
// — nicht, dass die Tabelle leer ist: der Worker füllt sie im Betrieb.
|
||||||
|
const mitRechten = await db.roleMapping.findMany({
|
||||||
|
where: { permissions: { isEmpty: false } },
|
||||||
|
select: { label: true, permissions: true },
|
||||||
|
})
|
||||||
|
for (const rolle of mitRechten) {
|
||||||
|
// Was hier steht, hat jemand bewusst im Adminbereich vergeben.
|
||||||
|
expect(rolle.permissions.length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
expect(await db.appSetting.count()).toBe(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { de } from '@/lib/i18n/de'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: de.auth.error.title }
|
||||||
|
|
||||||
|
export default async function SignInErrorPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ grund?: string }>
|
||||||
|
}) {
|
||||||
|
const { grund } = await searchParams
|
||||||
|
const reason = (grund ? de.auth.reasons[grund] : undefined) ?? de.auth.error
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main id="inhalt" className="mx-auto max-w-(--container-measure) px-6 py-16">
|
||||||
|
<h1 className="font-condensed">{reason.title}</h1>
|
||||||
|
<p className="mt-2 text-text-dim">{reason.body}</p>
|
||||||
|
<p className="mt-6">
|
||||||
|
<Link href="/anmelden">{de.auth.backToSignIn}</Link>
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { signIn } from '@/auth'
|
||||||
|
import { getCurrentUser } from '@/lib/auth/current'
|
||||||
|
import { de } from '@/lib/i18n/de'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: de.auth.signIn }
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default async function SignInPage() {
|
||||||
|
if (await getCurrentUser()) redirect('/uebersicht')
|
||||||
|
|
||||||
|
async function anmelden() {
|
||||||
|
'use server'
|
||||||
|
await signIn('discord', { redirectTo: '/uebersicht' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main id="inhalt" className="mx-auto max-w-(--container-measure) px-6 py-16">
|
||||||
|
<h1 className="font-condensed">{de.app.name}</h1>
|
||||||
|
<p className="mt-2 text-text-dim">{de.auth.intro}</p>
|
||||||
|
|
||||||
|
<form action={anmelden} className="mt-8">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="border border-line bg-raised px-4 py-2 font-condensed text-sm text-accent"
|
||||||
|
>
|
||||||
|
{de.auth.signIn}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from '@/auth'
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers
|
||||||
+4
-68
@@ -1,72 +1,8 @@
|
|||||||
import { Suspense } from 'react'
|
import { redirect } from 'next/navigation'
|
||||||
import { collectHealth, type ComponentState } from '@/lib/health/check'
|
import { getCurrentUser } from '@/lib/auth/current'
|
||||||
import { de } from '@/lib/i18n/de'
|
|
||||||
import { SkeletonLine } from '@/components/Skeleton'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
const STATE_LABEL: Record<ComponentState, string> = {
|
export default async function Root() {
|
||||||
up: de.health.ok,
|
redirect((await getCurrentUser()) ? '/uebersicht' : '/anmelden')
|
||||||
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 function Home() {
|
|
||||||
return (
|
|
||||||
<main id="inhalt" className="mx-auto max-w-(--container-measure) px-6 py-12">
|
|
||||||
<h1 className="font-condensed">{de.app.name}</h1>
|
|
||||||
<p className="mt-2 max-w-(--container-measure) text-text-dim">{de.app.description}</p>
|
|
||||||
|
|
||||||
<hr className="my-8 border-line" />
|
|
||||||
|
|
||||||
<h2 className="mb-4 font-condensed">{de.health.heading}</h2>
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="max-w-(--container-measure)">
|
|
||||||
<SkeletonLine width="14rem" />
|
|
||||||
<SkeletonLine />
|
|
||||||
<SkeletonLine />
|
|
||||||
<SkeletonLine />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SystemState />
|
|
||||||
</Suspense>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
import NextAuth from 'next-auth'
|
||||||
|
import Discord from 'next-auth/providers/discord'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { getEnv } from '@/lib/env'
|
||||||
|
import { fetchGuildMember } from '@/lib/auth/discord'
|
||||||
|
import { createSession, SESSION_MAX_AGE_MS } from '@/lib/auth/session'
|
||||||
|
import { invalidateUser } from '@/lib/auth/effective'
|
||||||
|
import { decideSignIn, SIGN_IN_ERRORS } from '@/lib/auth/signInPolicy'
|
||||||
|
|
||||||
|
export { SIGN_IN_ERRORS }
|
||||||
|
|
||||||
|
type DiscordProfile = {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
global_name?: string | null
|
||||||
|
avatar?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth klärt, wer jemand ist. Der Bot klärt, was er darf.
|
||||||
|
*
|
||||||
|
* Auth.js übernimmt hier ausschliesslich den OAuth2-Ablauf mit `state` und
|
||||||
|
* PKCE. Das Zugangstoken des Nutzers wird danach verworfen und nie wieder
|
||||||
|
* benutzt: die Rollen holt der Bot mit seinem eigenen Token.
|
||||||
|
*
|
||||||
|
* Die Sitzung selbst steht in der Datenbank. Im Cookie liegt nur ein
|
||||||
|
* signiertes Wertepaar aus `userId` und `sessionId` — ohne die Zeile in
|
||||||
|
* `Session` ist es wertlos. Nur so lässt sich eine Sitzung zurückziehen,
|
||||||
|
* wenn jemand seine Rolle verliert. Siehe docs/03-auth-und-rechte.md.
|
||||||
|
*/
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth(() => {
|
||||||
|
const env = getEnv()
|
||||||
|
|
||||||
|
return {
|
||||||
|
trustHost: true,
|
||||||
|
secret: env.AUTH_SECRET,
|
||||||
|
|
||||||
|
providers: [
|
||||||
|
Discord({
|
||||||
|
clientId: env.DISCORD_CLIENT_ID,
|
||||||
|
clientSecret: env.DISCORD_CLIENT_SECRET,
|
||||||
|
// Nur `identify`. Kein `email`, kein `guilds`: was der Nutzer auf
|
||||||
|
// anderen Servern tut, geht die Anwendung nichts an.
|
||||||
|
authorization: { params: { scope: 'identify' } },
|
||||||
|
checks: ['pkce', 'state'],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
|
session: { strategy: 'jwt', maxAge: SESSION_MAX_AGE_MS / 1000 },
|
||||||
|
|
||||||
|
cookies: {
|
||||||
|
sessionToken: {
|
||||||
|
name: 'afis.sitzung',
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
pages: {
|
||||||
|
signIn: '/anmelden',
|
||||||
|
error: '/anmelden/fehler',
|
||||||
|
},
|
||||||
|
|
||||||
|
callbacks: {
|
||||||
|
async signIn({ profile }) {
|
||||||
|
const discord = profile as DiscordProfile | undefined
|
||||||
|
if (!discord?.id) return `/anmelden/fehler?grund=${SIGN_IN_ERRORS.discordUnreachable}`
|
||||||
|
|
||||||
|
let member
|
||||||
|
try {
|
||||||
|
member = await fetchGuildMember(discord.id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[auth] Mitgliedschaft nicht prüfbar', error)
|
||||||
|
return `/anmelden/fehler?grund=${SIGN_IN_ERRORS.discordUnreachable}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBootstrapAdmin = env.BOOTSTRAP_ADMIN_IDS.includes(discord.id)
|
||||||
|
const grantingRoleCount = member
|
||||||
|
? await db.roleMapping.count({
|
||||||
|
where: { discordRoleId: { in: member.roleIds }, grantsMembership: true },
|
||||||
|
})
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const decision = decideSignIn({ member, isBootstrapAdmin, grantingRoleCount })
|
||||||
|
// Abgelehnt heisst: kein Konto anlegen, keine Spur hinterlassen.
|
||||||
|
if (!decision.ok) return `/anmelden/fehler?grund=${decision.reason}`
|
||||||
|
if (!member) return `/anmelden/fehler?grund=${SIGN_IN_ERRORS.notInGuild}`
|
||||||
|
|
||||||
|
const displayName = member.nickname ?? discord.global_name ?? discord.username
|
||||||
|
|
||||||
|
await db.$transaction([
|
||||||
|
db.user.upsert({
|
||||||
|
where: { id: discord.id },
|
||||||
|
create: {
|
||||||
|
id: discord.id,
|
||||||
|
username: discord.username,
|
||||||
|
displayName,
|
||||||
|
avatarHash: discord.avatar ?? null,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
username: discord.username,
|
||||||
|
displayName,
|
||||||
|
avatarHash: discord.avatar ?? null,
|
||||||
|
isActive: true,
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.userRole.deleteMany({
|
||||||
|
where: { userId: discord.id, discordRoleId: { notIn: member.roleIds } },
|
||||||
|
}),
|
||||||
|
...member.roleIds.map((discordRoleId) =>
|
||||||
|
db.userRole.upsert({
|
||||||
|
where: { userId_discordRoleId: { userId: discord.id, discordRoleId } },
|
||||||
|
create: { userId: discord.id, discordRoleId },
|
||||||
|
update: {},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
invalidateUser(discord.id)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
async jwt({ token, account, profile }) {
|
||||||
|
// Nur bei der ersten Anmeldung: eine Sitzungszeile anlegen.
|
||||||
|
if (account && profile) {
|
||||||
|
const discord = profile as DiscordProfile
|
||||||
|
token.userId = discord.id
|
||||||
|
token.sessionId = await createSession(discord.id)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
},
|
||||||
|
|
||||||
|
session({ session, token }) {
|
||||||
|
// In der Sitzung steht nur userId und sessionId. Rechte werden pro
|
||||||
|
// Request aufgelöst, nie im Token mitgeführt.
|
||||||
|
return Object.assign(session, {
|
||||||
|
userId: token.userId as string | undefined,
|
||||||
|
sessionId: token.sessionId as string | undefined,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
+32
-8
@@ -1,12 +1,5 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
test('Startseite liefert Titel und Systemzustand', async ({ page }) => {
|
|
||||||
await page.goto('/')
|
|
||||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('AfiS-Aktensystem')
|
|
||||||
await expect(page.getByRole('heading', { name: 'Systemzustand' })).toBeVisible()
|
|
||||||
await expect(page.getByRole('cell', { name: 'Datenbank' })).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Gesundheitsendpunkt antwortet mit allen drei Abhaengigkeiten', async ({ request }) => {
|
test('Gesundheitsendpunkt antwortet mit allen drei Abhaengigkeiten', async ({ request }) => {
|
||||||
const response = await request.get('/api/health')
|
const response = await request.get('/api/health')
|
||||||
expect([200, 503]).toContain(response.status())
|
expect([200, 503]).toContain(response.status())
|
||||||
@@ -15,10 +8,41 @@ test('Gesundheitsendpunkt antwortet mit allen drei Abhaengigkeiten', async ({ re
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('Antwort traegt eine strikte Inhaltsrichtlinie', async ({ page }) => {
|
test('Antwort traegt eine strikte Inhaltsrichtlinie', async ({ page }) => {
|
||||||
const response = await page.goto('/')
|
const response = await page.goto('/anmelden')
|
||||||
const csp = response?.headers()['content-security-policy'] ?? ''
|
const csp = response?.headers()['content-security-policy'] ?? ''
|
||||||
expect(csp).toContain("object-src 'none'")
|
expect(csp).toContain("object-src 'none'")
|
||||||
expect(csp).toContain("frame-ancestors 'none'")
|
expect(csp).toContain("frame-ancestors 'none'")
|
||||||
expect(csp).toMatch(/script-src [^;]*'nonce-/)
|
expect(csp).toMatch(/script-src [^;]*'nonce-/)
|
||||||
expect(csp).not.toMatch(/script-src [^;]*'unsafe-inline'/)
|
expect(csp).not.toMatch(/script-src [^;]*'unsafe-inline'/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Anmeldeseite bietet den Discord-Weg an', async ({ page }) => {
|
||||||
|
await page.goto('/anmelden')
|
||||||
|
await expect(page.getByRole('button', { name: 'Mit Discord anmelden' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Startseite fuehrt Nichtangemeldete zur Anmeldung', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page).toHaveURL(/\/anmelden$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Geschuetzte Bereiche', () => {
|
||||||
|
for (const pfad of ['/uebersicht', '/admin/rollen', '/admin/system']) {
|
||||||
|
test(`${pfad} ist ohne Anmeldung nicht erreichbar`, async ({ page }) => {
|
||||||
|
await page.goto(pfad)
|
||||||
|
await expect(page).toHaveURL(/\/anmelden$/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Fehlerseite benennt den Grund der Ablehnung', async ({ page }) => {
|
||||||
|
await page.goto('/anmelden/fehler?grund=keine-afis-rolle')
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
|
||||||
|
'Keine Berechtigung für das Aktensystem',
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/anmelden/fehler?grund=keine-mitgliedschaft')
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
|
||||||
|
'Du bist nicht auf dem AfiS-Discord',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Prisma } from '@afis/db'
|
||||||
|
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where-Fragment für alle Akten, die ein Nutzer lesen darf.
|
||||||
|
*
|
||||||
|
* **Die Prüfung wird nie in JavaScript nachgelagert.** Trefferzahlen und
|
||||||
|
* Paginierung laufen über dieselbe Abfrage, damit sich aus Lücken nichts
|
||||||
|
* ableiten lässt. Siehe docs/03-auth-und-rechte.md.
|
||||||
|
*
|
||||||
|
* Reihenfolge nach dem Dokument, erste zutreffende Regel gewinnt:
|
||||||
|
* 1. Ausschluss → kein Zugriff, egal welche Stufe
|
||||||
|
* 2. Admin → Zugriff
|
||||||
|
* 3. Beteiligter → Zugriff
|
||||||
|
* 4. Stufe reicht → Zugriff
|
||||||
|
* 5. sonst kein Zugriff
|
||||||
|
*
|
||||||
|
* Der Ausschluss steht auch über dem Admin. Das Codebeispiel im Dokument gibt
|
||||||
|
* für Admins `{}` zurück und übergeht ihn damit — das widerspricht der
|
||||||
|
* Reihenfolge eine Zeile darüber. Hier gilt die Reihenfolge: der Ausschluss
|
||||||
|
* deckt gerade den Fall ab, dass gegen einen hochrangigen Mitarbeiter
|
||||||
|
* ermittelt wird, und der Administrator ist einer.
|
||||||
|
* Siehe docs/13-entscheidungen.md.
|
||||||
|
*/
|
||||||
|
export function visibleCaseWhere(p: EffectivePermissions): Prisma.CaseWhereInput {
|
||||||
|
const notExcluded: Prisma.CaseWhereInput = {
|
||||||
|
exclusions: { none: { userId: p.userId } },
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.isAdmin) return notExcluded
|
||||||
|
|
||||||
|
const conditions: Prisma.CaseWhereInput[] = [
|
||||||
|
notExcluded,
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ participants: { some: { userId: p.userId } } },
|
||||||
|
{ classification: { lte: p.clearanceLevel } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Versiegelte Akten sind nur mit Recht 'admin' oder Stufe 5 lesbar.
|
||||||
|
// Siehe docs/05-akten-und-vorlagen.md, Lebenszyklus.
|
||||||
|
if (p.clearanceLevel < 5) {
|
||||||
|
conditions.push({ status: { not: 'SEALED' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { AND: conditions }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dasselbe für Verlaufseinträge: die Akte muss lesbar sein **und** die
|
||||||
|
* Feldstufe des Eintrags darf die Stufe des Lesers nicht übersteigen.
|
||||||
|
*/
|
||||||
|
export function visibleEntryWhere(p: EffectivePermissions): Prisma.CaseEntryWhereInput {
|
||||||
|
if (p.isAdmin) {
|
||||||
|
return { case: visibleCaseWhere(p) }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
AND: [{ case: visibleCaseWhere(p) }, { minClearance: { lte: p.clearanceLevel } }],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Prozesslokaler Cache mit Verfallszeit und Obergrenze.
|
||||||
|
*
|
||||||
|
* Die Rechteauflösung läuft einmal pro Request. Ohne Cache wären das zwei
|
||||||
|
* Abfragen auf jeder Seite, mit Cache praktisch keine. Die Verfallszeit ist
|
||||||
|
* nur das Sicherheitsnetz: der eigentliche Weg ist die sofortige Verwerfung
|
||||||
|
* über `pg_notify`, siehe lib/realtime/listener.ts und docs/03-auth-und-rechte.md.
|
||||||
|
*/
|
||||||
|
export class TtlCache<K, V> {
|
||||||
|
readonly #entries = new Map<K, { value: V; expiresAt: number }>()
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly ttlMs: number,
|
||||||
|
private readonly maxEntries: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get(key: K): V | undefined {
|
||||||
|
const entry = this.#entries.get(key)
|
||||||
|
if (!entry) return undefined
|
||||||
|
if (entry.expiresAt <= Date.now()) {
|
||||||
|
this.#entries.delete(key)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
// Auffrischen, damit die Reihenfolge der Map die Nutzung widerspiegelt.
|
||||||
|
this.#entries.delete(key)
|
||||||
|
this.#entries.set(key, entry)
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: K, value: V): void {
|
||||||
|
this.#entries.delete(key)
|
||||||
|
this.#entries.set(key, { value, expiresAt: Date.now() + this.ttlMs })
|
||||||
|
while (this.#entries.size > this.maxEntries) {
|
||||||
|
const oldest = this.#entries.keys().next()
|
||||||
|
if (oldest.done) break
|
||||||
|
this.#entries.delete(oldest.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: K): void {
|
||||||
|
this.#entries.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.#entries.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.#entries.size
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { notFound, redirect } from 'next/navigation'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { loadSession } from '@/lib/auth/session'
|
||||||
|
import { resolvePermissions } from '@/lib/auth/effective'
|
||||||
|
import { ensureListening } from '@/lib/realtime/listener'
|
||||||
|
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
|
||||||
|
|
||||||
|
export type CurrentUser = {
|
||||||
|
id: string
|
||||||
|
displayName: string
|
||||||
|
username: string
|
||||||
|
avatarHash: string | null
|
||||||
|
sessionId: string
|
||||||
|
permissions: EffectivePermissions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der angemeldete Nutzer für genau diesen Request, oder `null`.
|
||||||
|
*
|
||||||
|
* Drei Prüfungen, alle serverseitig:
|
||||||
|
* 1. Das Cookie trägt eine gültige Signatur (Auth.js)
|
||||||
|
* 2. Die Sitzungszeile existiert noch und ist nicht abgelaufen
|
||||||
|
* 3. Der Nutzer trägt weiterhin eine Rolle, die zur Anmeldung berechtigt
|
||||||
|
*
|
||||||
|
* Schritt 3 ist doppelt gemoppelt — der Worker beendet Sitzungen beim
|
||||||
|
* Rollenverlust bereits von sich aus — aber genau das ist der Sinn: fällt der
|
||||||
|
* Worker aus, verliert trotzdem niemand unbemerkt seinen Ausschluss.
|
||||||
|
*/
|
||||||
|
export async function getCurrentUser(): Promise<CurrentUser | null> {
|
||||||
|
const token = await auth()
|
||||||
|
const sessionId = (token as { sessionId?: string } | null)?.sessionId
|
||||||
|
if (!sessionId) return null
|
||||||
|
|
||||||
|
// Der LISTEN-Client wird beim ersten Request des Prozesses aufgebaut.
|
||||||
|
await ensureListening()
|
||||||
|
|
||||||
|
const session = await loadSession(sessionId)
|
||||||
|
if (!session) return null
|
||||||
|
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: session.userId },
|
||||||
|
select: { id: true, displayName: true, username: true, avatarHash: true, isActive: true },
|
||||||
|
})
|
||||||
|
if (!user || !user.isActive) return null
|
||||||
|
|
||||||
|
const permissions = await resolvePermissions(session.userId)
|
||||||
|
if (!permissions.hasMembership && !permissions.isAdmin) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
displayName: user.displayName,
|
||||||
|
username: user.username,
|
||||||
|
avatarHash: user.avatarHash,
|
||||||
|
sessionId: session.id,
|
||||||
|
permissions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Für Seiten, die eine Anmeldung voraussetzen. */
|
||||||
|
export async function requireUser(): Promise<CurrentUser> {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) redirect('/anmelden')
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Für Seiten und Aktionen, die ein Recht voraussetzen.
|
||||||
|
*
|
||||||
|
* Fehlt das Recht, gibt es 404 statt 403. Existenz ist auch eine Information:
|
||||||
|
* aus einem 403 liesse sich ablesen, dass es die Seite gibt.
|
||||||
|
* Siehe docs/03-auth-und-rechte.md.
|
||||||
|
*/
|
||||||
|
export async function requirePermission(permission: Permission): Promise<CurrentUser> {
|
||||||
|
const user = await requireUser()
|
||||||
|
if (!user.permissions.isAdmin && !user.permissions.permissions.has(permission)) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Für Server Actions: wirft, statt umzuleiten. Jede Server Action prüft die
|
||||||
|
* Berechtigung erneut, egal was die Oberfläche vorher versteckt hat.
|
||||||
|
* Siehe docs/07-dateien-und-sicherheit.md.
|
||||||
|
*/
|
||||||
|
export async function requirePermissionForAction(permission: Permission): Promise<CurrentUser> {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) throw new Error('Nicht angemeldet')
|
||||||
|
if (!user.permissions.isAdmin && !user.permissions.permissions.has(permission)) {
|
||||||
|
throw new Error('Keine Berechtigung')
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { getEnv } from '@/lib/env'
|
||||||
|
|
||||||
|
const DISCORD_API = 'https://discord.com/api/v10'
|
||||||
|
|
||||||
|
export type GuildMember = {
|
||||||
|
userId: string
|
||||||
|
roleIds: string[]
|
||||||
|
nickname: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragt die Mitgliedschaft **serverseitig mit dem Bot-Token** ab.
|
||||||
|
*
|
||||||
|
* Das Zugangstoken des Nutzers aus dem OAuth-Ablauf wird dafür bewusst nicht
|
||||||
|
* benutzt und nach dem Callback verworfen: OAuth klärt, wer jemand ist, der Bot
|
||||||
|
* klärt, was er darf. Siehe docs/03-auth-und-rechte.md.
|
||||||
|
*
|
||||||
|
* `null` bedeutet: kein Mitglied der Guild. Ein Fehler der Discord-API wird
|
||||||
|
* geworfen, damit ein Ausfall nicht wie eine abgelehnte Anmeldung aussieht.
|
||||||
|
*/
|
||||||
|
export async function fetchGuildMember(userId: string): Promise<GuildMember | null> {
|
||||||
|
const env = getEnv()
|
||||||
|
const response = await fetch(`${DISCORD_API}/guilds/${env.DISCORD_GUILD_ID}/members/${userId}`, {
|
||||||
|
headers: { authorization: `Bot ${env.DISCORD_BOT_TOKEN}` },
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 404) return null
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Discord-API antwortete mit ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json()) as { roles?: string[]; nick?: string | null }
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
roleIds: body.roles ?? [],
|
||||||
|
nickname: body.nick ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { getEnv } from '@/lib/env'
|
||||||
|
import { TtlCache } from '@/lib/auth/cache'
|
||||||
|
import {
|
||||||
|
PERMISSIONS,
|
||||||
|
isPermission,
|
||||||
|
type EffectivePermissions,
|
||||||
|
type Permission,
|
||||||
|
} from '@/lib/auth/permissions'
|
||||||
|
|
||||||
|
/** Höchste Freigabestufe. */
|
||||||
|
const MAX_CLEARANCE = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 60 Sekunden Verfallszeit als Sicherheitsnetz. Der reguläre Weg ist die
|
||||||
|
* sofortige Verwerfung bei `member_changed` oder `role_mapping_changed`.
|
||||||
|
*/
|
||||||
|
const cache = new TtlCache<string, EffectivePermissions>(60_000, 200)
|
||||||
|
|
||||||
|
/** Wird vom LISTEN-Client aufgerufen, siehe lib/realtime/listener.ts. */
|
||||||
|
export function invalidateUser(userId: string): void {
|
||||||
|
cache.delete(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Änderung am Rollen-Mapping betrifft potenziell jeden. */
|
||||||
|
export function invalidateAll(): void {
|
||||||
|
cache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
function bootstrapAdmin(userId: string): EffectivePermissions {
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
clearanceLevel: MAX_CLEARANCE,
|
||||||
|
permissions: new Set<Permission>(PERMISSIONS),
|
||||||
|
isAdmin: true,
|
||||||
|
hasMembership: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst die Rechte eines Nutzers aus `UserRole` × `RoleMapping` auf:
|
||||||
|
* höchste Stufe aller Rollen, Vereinigung aller Rechte.
|
||||||
|
*
|
||||||
|
* `hasMembership` ist eine Ergänzung gegenüber docs/03-auth-und-rechte.md und
|
||||||
|
* bildet den Türsteher ab: auf dem AfiS-Discord sind auch Gäste, die keine
|
||||||
|
* Akte sehen dürfen. Siehe docs/13-entscheidungen.md.
|
||||||
|
*
|
||||||
|
* Notfall-Admins aus `BOOTSTRAP_ADMIN_IDS` bekommen immer Stufe 5 und alle
|
||||||
|
* Rechte, ohne Datenbankabfrage. Ohne das sperrt man sich beim ersten Fehler
|
||||||
|
* in der Rollenverwaltung selbst aus.
|
||||||
|
*/
|
||||||
|
export async function resolvePermissions(userId: string): Promise<EffectivePermissions> {
|
||||||
|
const cached = cache.get(userId)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
if (getEnv().BOOTSTRAP_ADMIN_IDS.includes(userId)) {
|
||||||
|
const resolved = bootstrapAdmin(userId)
|
||||||
|
cache.set(userId, resolved)
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Abfrage, kein Nachfiltern in JavaScript.
|
||||||
|
const rows = await db.userRole.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: {
|
||||||
|
discordRoleId: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const roleIds = rows.map((r) => r.discordRoleId)
|
||||||
|
const mappings =
|
||||||
|
roleIds.length === 0
|
||||||
|
? []
|
||||||
|
: await db.roleMapping.findMany({
|
||||||
|
where: { discordRoleId: { in: roleIds } },
|
||||||
|
select: { clearanceLevel: true, permissions: true, grantsMembership: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
let clearanceLevel = 0
|
||||||
|
let hasMembership = false
|
||||||
|
const permissions = new Set<Permission>()
|
||||||
|
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
if (mapping.clearanceLevel > clearanceLevel) clearanceLevel = mapping.clearanceLevel
|
||||||
|
if (mapping.grantsMembership) hasMembership = true
|
||||||
|
for (const value of mapping.permissions) {
|
||||||
|
if (isPermission(value)) permissions.add(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved: EffectivePermissions = {
|
||||||
|
userId,
|
||||||
|
clearanceLevel: Math.min(clearanceLevel, MAX_CLEARANCE),
|
||||||
|
permissions,
|
||||||
|
isAdmin: permissions.has('admin'),
|
||||||
|
hasMembership,
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(userId, resolved)
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
@@ -50,6 +50,12 @@ export type EffectivePermissions = {
|
|||||||
permissions: Set<Permission>
|
permissions: Set<Permission>
|
||||||
/** Aus BOOTSTRAP_ADMIN_IDS oder Recht 'admin'. */
|
/** Aus BOOTSTRAP_ADMIN_IDS oder Recht 'admin'. */
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
|
/**
|
||||||
|
* Trägt der Nutzer eine Rolle, die zur Anmeldung berechtigt? Ergänzung
|
||||||
|
* gegenüber docs/03-auth-und-rechte.md: auf dem AfiS-Discord sind auch
|
||||||
|
* Gäste. Siehe docs/13-entscheidungen.md.
|
||||||
|
*/
|
||||||
|
hasMembership: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function can(p: EffectivePermissions, permission: Permission): boolean {
|
export function can(p: EffectivePermissions, permission: Permission): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { db } from '@/lib/db'
|
||||||
|
|
||||||
|
/** Laufzeit einer Sitzung, gleitend verlängert. */
|
||||||
|
export const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erst nachschreiben, wenn eine Stunde vergangen ist. Sonst erzeugt jeder
|
||||||
|
* Seitenaufruf einen Schreibvorgang, nur um den Ablauf um Sekunden zu schieben.
|
||||||
|
*/
|
||||||
|
const SLIDE_THRESHOLD_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
|
export type ActiveSession = {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
expiresAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(userId: string, userAgent?: string): Promise<string> {
|
||||||
|
const session = await db.session.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
expiresAt: new Date(Date.now() + SESSION_MAX_AGE_MS),
|
||||||
|
userAgent: userAgent?.slice(0, 500) ?? null,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
return session.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt eine Sitzung und verlängert sie gleitend. Gibt `null` zurück, wenn sie
|
||||||
|
* nicht mehr existiert oder abgelaufen ist.
|
||||||
|
*
|
||||||
|
* Die Zeile in der Datenbank ist der eigentliche Sitzungsnachweis: das Cookie
|
||||||
|
* allein lässt sich nicht zurückziehen. Verliert jemand seine Rolle, löscht der
|
||||||
|
* Worker die Zeile, und der nächste Request ist abgemeldet.
|
||||||
|
* Siehe docs/03-auth-und-rechte.md.
|
||||||
|
*/
|
||||||
|
export async function loadSession(sessionId: string): Promise<ActiveSession | null> {
|
||||||
|
const session = await db.session.findUnique({
|
||||||
|
where: { id: sessionId },
|
||||||
|
select: { id: true, userId: true, expiresAt: true, lastSeenAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!session) return null
|
||||||
|
|
||||||
|
if (session.expiresAt.getTime() <= Date.now()) {
|
||||||
|
await db.session.delete({ where: { id: sessionId } }).catch(() => undefined)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - session.lastSeenAt.getTime() > SLIDE_THRESHOLD_MS) {
|
||||||
|
const now = new Date()
|
||||||
|
await db.session
|
||||||
|
.update({
|
||||||
|
where: { id: sessionId },
|
||||||
|
data: { lastSeenAt: now, expiresAt: new Date(now.getTime() + SESSION_MAX_AGE_MS) },
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: session.id, userId: session.userId, expiresAt: session.expiresAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function endSession(sessionId: string): Promise<void> {
|
||||||
|
await db.session.delete({ where: { id: sessionId } }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wird beim Rollenverlust und beim Serveraustritt benutzt. */
|
||||||
|
export async function endAllSessionsFor(userId: string): Promise<number> {
|
||||||
|
const { count } = await db.session.deleteMany({ where: { userId } })
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tägliche Aufräumaufgabe, siehe docs/11-betrieb.md. */
|
||||||
|
export async function deleteExpiredSessions(): Promise<number> {
|
||||||
|
const { count } = await db.session.deleteMany({ where: { expiresAt: { lte: new Date() } } })
|
||||||
|
return count
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { GuildMember } from '@/lib/auth/discord'
|
||||||
|
|
||||||
|
export const SIGN_IN_ERRORS = {
|
||||||
|
notInGuild: 'keine-mitgliedschaft',
|
||||||
|
notAfis: 'keine-afis-rolle',
|
||||||
|
discordUnreachable: 'discord-nicht-erreichbar',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type SignInReason = (typeof SIGN_IN_ERRORS)[keyof typeof SIGN_IN_ERRORS]
|
||||||
|
|
||||||
|
export type SignInDecision = { ok: true } | { ok: false; reason: SignInReason }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wer darf sich anmelden.
|
||||||
|
*
|
||||||
|
* Bewusst als eigene Funktion und nicht im Auth.js-Callback vergraben: das ist
|
||||||
|
* die Regel, an der alles hängt, und sie gehört einzeln prüfbar.
|
||||||
|
*
|
||||||
|
* - Kein Mitglied der Guild → abgelehnt. Es wird kein Konto angelegt.
|
||||||
|
* - Mitglied ohne Rolle mit `grantsMembership` → abgelehnt. Auf dem
|
||||||
|
* AfiS-Discord sind auch Gäste, die keine Akte sehen dürfen.
|
||||||
|
* - Notfall-Admin → durchgelassen, aber nur solange er Mitglied der Guild
|
||||||
|
* ist. Sonst bliebe die Hintertür auch nach einem Rauswurf offen.
|
||||||
|
*
|
||||||
|
* Siehe docs/03-auth-und-rechte.md und docs/13-entscheidungen.md.
|
||||||
|
*/
|
||||||
|
export function decideSignIn(input: {
|
||||||
|
member: GuildMember | null
|
||||||
|
isBootstrapAdmin: boolean
|
||||||
|
grantingRoleCount: number
|
||||||
|
}): SignInDecision {
|
||||||
|
if (!input.member) return { ok: false, reason: SIGN_IN_ERRORS.notInGuild }
|
||||||
|
if (input.isBootstrapAdmin) return { ok: true }
|
||||||
|
if (input.grantingRoleCount === 0) return { ok: false, reason: SIGN_IN_ERRORS.notAfis }
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
+66
-1
@@ -29,10 +29,22 @@ export const de = {
|
|||||||
signIn: 'Mit Discord anmelden',
|
signIn: 'Mit Discord anmelden',
|
||||||
signOut: 'Abmelden',
|
signOut: 'Abmelden',
|
||||||
signedInAs: 'Angemeldet als',
|
signedInAs: 'Angemeldet als',
|
||||||
notInGuild: {
|
intro:
|
||||||
|
'Die Anmeldung läuft über Discord. Zugriff haben Mitglieder der Abteilung für innere Sicherheit.',
|
||||||
|
reasons: {
|
||||||
|
'keine-mitgliedschaft': {
|
||||||
title: 'Du bist nicht auf dem AfiS-Discord',
|
title: 'Du bist nicht auf dem AfiS-Discord',
|
||||||
body: 'Die Anmeldung setzt eine Mitgliedschaft auf dem Discord-Server voraus. Wende dich an die Abteilungsleitung.',
|
body: 'Die Anmeldung setzt eine Mitgliedschaft auf dem Discord-Server voraus. Wende dich an die Abteilungsleitung.',
|
||||||
},
|
},
|
||||||
|
'keine-afis-rolle': {
|
||||||
|
title: 'Keine Berechtigung für das Aktensystem',
|
||||||
|
body: 'Du bist auf dem Server, trägst aber keine Rolle der Abteilung. Wende dich an die Abteilungsleitung.',
|
||||||
|
},
|
||||||
|
'discord-nicht-erreichbar': {
|
||||||
|
title: 'Discord antwortet nicht',
|
||||||
|
body: 'Die Mitgliedschaft war nicht prüfbar. In einigen Minuten erneut versuchen.',
|
||||||
|
},
|
||||||
|
} as Record<string, { title: string; body: string }>,
|
||||||
sessionEnded: {
|
sessionEnded: {
|
||||||
title: 'Sitzung beendet',
|
title: 'Sitzung beendet',
|
||||||
body: 'Deine Rollen haben sich geändert. Melde dich neu an.',
|
body: 'Deine Rollen haben sich geändert. Melde dich neu an.',
|
||||||
@@ -41,6 +53,59 @@ export const de = {
|
|||||||
title: 'Anmeldung nicht möglich',
|
title: 'Anmeldung nicht möglich',
|
||||||
body: 'Der Anmeldevorgang wurde abgebrochen. Erneut versuchen.',
|
body: 'Der Anmeldevorgang wurde abgebrochen. Erneut versuchen.',
|
||||||
},
|
},
|
||||||
|
backToSignIn: 'Zurück zur Anmeldung',
|
||||||
|
},
|
||||||
|
|
||||||
|
overview: {
|
||||||
|
heading: 'Dein Zugang',
|
||||||
|
intro: 'Was das System über dich weiss und was daraus folgt.',
|
||||||
|
person: 'Person',
|
||||||
|
discordId: 'Discord-Kennung',
|
||||||
|
roles: 'Rollen auf dem Discord',
|
||||||
|
clearance: 'Errechnete Freigabestufe',
|
||||||
|
permissions: 'Rechte',
|
||||||
|
noPermissions: 'Keine Rechte. Die Abteilungsleitung vergibt sie in der Rollenverwaltung.',
|
||||||
|
noRoles: 'Keine Rollen.',
|
||||||
|
bootstrapAdmin: 'Notfallzugang aus der Konfiguration, unabhängig vom Rollen-Mapping',
|
||||||
|
unmapped: 'nicht zugeordnet',
|
||||||
|
},
|
||||||
|
|
||||||
|
roleAdmin: {
|
||||||
|
heading: 'Rollenverwaltung',
|
||||||
|
intro:
|
||||||
|
'Jede Rolle des Discord-Servers steht hier. Zugangsrollen bestimmen die Freigabestufe, Rangrollen die Rechte, und eine Zugehörigkeitsrolle entscheidet, wer sich überhaupt anmelden darf.',
|
||||||
|
empty:
|
||||||
|
'Noch keine Rollen eingelesen. Der Worker spiegelt sie, sobald er am Discord-Gateway hängt.',
|
||||||
|
role: 'Rolle',
|
||||||
|
category: 'Sorte',
|
||||||
|
clearance: 'Freigabestufe',
|
||||||
|
grantsMembership: 'Darf sich anmelden',
|
||||||
|
permissions: 'Rechte',
|
||||||
|
missingFromGuild: 'Auf dem Server nicht mehr vorhanden',
|
||||||
|
save: 'Zuordnung speichern',
|
||||||
|
invalidInput: 'Eingabe nicht gültig',
|
||||||
|
roleGone: 'Rolle nicht gefunden, sie wurde zwischenzeitlich gelöscht',
|
||||||
|
saving: 'Wird gespeichert',
|
||||||
|
saved: 'Zuordnung gespeichert',
|
||||||
|
confirm: 'Rollen-Mapping ändern? Das wirkt sofort für alle angemeldeten Personen.',
|
||||||
|
categories: {
|
||||||
|
MEMBERSHIP: 'Zugehörigkeit',
|
||||||
|
RANK: 'Rang',
|
||||||
|
CLEARANCE: 'Zugang',
|
||||||
|
UNSORTED: 'Nicht eingeordnet',
|
||||||
|
} as Record<string, string>,
|
||||||
|
optIn:
|
||||||
|
'Dieses Recht ist im Auslieferungszustand keiner Rolle zugeordnet. Vergib es bewusst.',
|
||||||
|
preview: {
|
||||||
|
heading: 'Vorschau',
|
||||||
|
intro: 'Was jemand mit dieser Stufe sieht, wenn er keine weiteren Rollen hat.',
|
||||||
|
level: 'Stufe',
|
||||||
|
sees: 'Sieht Akten bis einschliesslich',
|
||||||
|
hidden: 'Höhere Akten sind unsichtbar',
|
||||||
|
lockedOnly: 'Höhere Akten sind sichtbar, aber gesperrt',
|
||||||
|
nobody: 'Keine Rolle führt zu dieser Stufe',
|
||||||
|
viaRoles: 'Erreichbar über',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
clearance: {
|
clearance: {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Client } from 'pg'
|
||||||
|
import { invalidateAll, invalidateUser } from '@/lib/auth/effective'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein einziger `pg`-Client hält `LISTEN` offen und verteilt im Prozess weiter.
|
||||||
|
* Kein WebSocket, kein Redis: Postgres `LISTEN/NOTIFY` reicht bei dieser
|
||||||
|
* Nutzerzahl vollständig. Siehe docs/06-chat-und-realtime.md.
|
||||||
|
*
|
||||||
|
* Phase 1 nutzt zwei Kanäle: `member_changed` verwirft die Rechte eines
|
||||||
|
* Nutzers, `role_mapping_changed` verwirft alles. Dadurch ist eine
|
||||||
|
* Rechteänderung in Discord in unter einer Sekunde wirksam, ohne dass ein
|
||||||
|
* einziger Request auf die Discord-API wartet.
|
||||||
|
*
|
||||||
|
* Die Verbindung läuft an pgbouncer vorbei: im Transaktionsmodus überlebt ein
|
||||||
|
* `LISTEN` den Pool nicht.
|
||||||
|
*/
|
||||||
|
const CHANNELS = ['member_changed', 'role_mapping_changed'] as const
|
||||||
|
|
||||||
|
type Listener = { client: Client; ready: Promise<void> }
|
||||||
|
|
||||||
|
const globalForListener = globalThis as unknown as { afisListener?: Listener }
|
||||||
|
|
||||||
|
function connect(): Listener {
|
||||||
|
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
|
||||||
|
if (!url) throw new Error('Konfiguration unvollständig: DIRECT_DATABASE_URL fehlt')
|
||||||
|
|
||||||
|
const client = new Client({ connectionString: url })
|
||||||
|
|
||||||
|
client.on('notification', (message) => {
|
||||||
|
if (message.channel === 'member_changed' && message.payload) {
|
||||||
|
invalidateUser(message.payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (message.channel === 'role_mapping_changed') {
|
||||||
|
invalidateAll()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on('error', (error: unknown) => {
|
||||||
|
console.error('[realtime] Verbindung verloren, baue neu auf', error)
|
||||||
|
globalForListener.afisListener = undefined
|
||||||
|
// Der nächste Aufruf von ensureListening baut neu auf. Bis dahin greift
|
||||||
|
// die Verfallszeit des Caches, es geht also nichts verloren, es dauert nur
|
||||||
|
// bis zu 60 Sekunden statt unter einer.
|
||||||
|
client.end().catch(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
const ready = client
|
||||||
|
.connect()
|
||||||
|
.then(async () => {
|
||||||
|
for (const channel of CHANNELS) {
|
||||||
|
await client.query(`LISTEN ${channel}`)
|
||||||
|
}
|
||||||
|
console.log('[realtime] hört auf', CHANNELS.join(', '))
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
globalForListener.afisListener = undefined
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
|
||||||
|
return { client, ready }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt sicher, dass der Prozess auf Änderungen hört. Mehrfach aufrufbar.
|
||||||
|
* Schlägt der Aufbau fehl, ist das kein Grund, den Request scheitern zu lassen:
|
||||||
|
* der Cache verfällt dann eben nach 60 Sekunden von selbst.
|
||||||
|
*/
|
||||||
|
export async function ensureListening(): Promise<void> {
|
||||||
|
globalForListener.afisListener ??= connect()
|
||||||
|
try {
|
||||||
|
await globalForListener.afisListener.ready
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[realtime] konnte nicht verbinden', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { decideSignIn, SIGN_IN_ERRORS } from '@/lib/auth/signInPolicy'
|
||||||
|
import type { GuildMember } from '@/lib/auth/discord'
|
||||||
|
|
||||||
|
const mitglied: GuildMember = { userId: '42', roleIds: ['r-afis'], nickname: null }
|
||||||
|
|
||||||
|
describe('Wer sich anmelden darf', () => {
|
||||||
|
it('lehnt ab, wer nicht auf dem Discord-Server ist', () => {
|
||||||
|
const entscheidung = decideSignIn({
|
||||||
|
member: null,
|
||||||
|
isBootstrapAdmin: false,
|
||||||
|
grantingRoleCount: 0,
|
||||||
|
})
|
||||||
|
expect(entscheidung).toEqual({ ok: false, reason: SIGN_IN_ERRORS.notInGuild })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lehnt auch einen Notfall-Admin ab, der die Guild verlassen hat', () => {
|
||||||
|
// Sonst bliebe die Hintertür nach einem Rauswurf offen.
|
||||||
|
const entscheidung = decideSignIn({
|
||||||
|
member: null,
|
||||||
|
isBootstrapAdmin: true,
|
||||||
|
grantingRoleCount: 0,
|
||||||
|
})
|
||||||
|
expect(entscheidung).toEqual({ ok: false, reason: SIGN_IN_ERRORS.notInGuild })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lehnt einen Gast ohne AfiS-Rolle ab', () => {
|
||||||
|
const entscheidung = decideSignIn({
|
||||||
|
member: { ...mitglied, roleIds: ['r-gast'] },
|
||||||
|
isBootstrapAdmin: false,
|
||||||
|
grantingRoleCount: 0,
|
||||||
|
})
|
||||||
|
expect(entscheidung).toEqual({ ok: false, reason: SIGN_IN_ERRORS.notAfis })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('laesst ein Mitglied mit AfiS-Rolle durch', () => {
|
||||||
|
expect(decideSignIn({ member: mitglied, isBootstrapAdmin: false, grantingRoleCount: 1 })).toEqual(
|
||||||
|
{ ok: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('laesst den Notfall-Admin ohne AfiS-Rolle durch', () => {
|
||||||
|
expect(decideSignIn({ member: mitglied, isBootstrapAdmin: true, grantingRoleCount: 0 })).toEqual(
|
||||||
|
{ ok: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Mitgliedschaftsabfrage beim Bot', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
})
|
||||||
|
|
||||||
|
function stubEnv() {
|
||||||
|
vi.stubEnv('DATABASE_URL', 'postgresql://x/y')
|
||||||
|
vi.stubEnv('APP_URL', 'http://localhost:3000')
|
||||||
|
vi.stubEnv('AUTH_SECRET', 'x'.repeat(40))
|
||||||
|
vi.stubEnv('DISCORD_CLIENT_ID', 'c')
|
||||||
|
vi.stubEnv('DISCORD_CLIENT_SECRET', 's')
|
||||||
|
vi.stubEnv('DISCORD_BOT_TOKEN', 'bot-token')
|
||||||
|
vi.stubEnv('DISCORD_GUILD_ID', '99')
|
||||||
|
}
|
||||||
|
|
||||||
|
it('meldet null, wenn Discord 404 antwortet', async () => {
|
||||||
|
stubEnv()
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 404 })))
|
||||||
|
const { resetEnvCache } = await import('@/lib/env')
|
||||||
|
resetEnvCache()
|
||||||
|
const { fetchGuildMember } = await import('@/lib/auth/discord')
|
||||||
|
|
||||||
|
expect(await fetchGuildMember('42')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wirft bei einem Ausfall der Discord-API, statt Mitgliedschaft zu verneinen', async () => {
|
||||||
|
// Ein Ausfall darf nicht aussehen wie "kein Mitglied": sonst sperrt eine
|
||||||
|
// Störung bei Discord die halbe Abteilung aus und legt nebenbei Konten an.
|
||||||
|
stubEnv()
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 500 })))
|
||||||
|
const { resetEnvCache } = await import('@/lib/env')
|
||||||
|
resetEnvCache()
|
||||||
|
const { fetchGuildMember } = await import('@/lib/auth/discord')
|
||||||
|
|
||||||
|
await expect(fetchGuildMember('42')).rejects.toThrow(/500/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('benutzt das Bot-Token, nicht das des Nutzers', async () => {
|
||||||
|
stubEnv()
|
||||||
|
const spy = vi.fn(async () => Response.json({ roles: ['r-afis'], nick: 'Vogel' }))
|
||||||
|
vi.stubGlobal('fetch', spy)
|
||||||
|
const { resetEnvCache } = await import('@/lib/env')
|
||||||
|
resetEnvCache()
|
||||||
|
const { fetchGuildMember } = await import('@/lib/auth/discord')
|
||||||
|
|
||||||
|
const member = await fetchGuildMember('42')
|
||||||
|
expect(member).toEqual({ userId: '42', roleIds: ['r-afis'], nickname: 'Vogel' })
|
||||||
|
|
||||||
|
const call = spy.mock.calls[0] as unknown as [string, RequestInit]
|
||||||
|
const [url, init] = call
|
||||||
|
expect(url).toContain('/guilds/99/members/42')
|
||||||
|
expect((init.headers as Record<string, string>).authorization).toBe('Bot bot-token')
|
||||||
|
})
|
||||||
|
})
|
||||||
+38
-4
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { de } from '@/lib/i18n/de'
|
import { de } from '@/lib/i18n/de'
|
||||||
|
import { OPT_IN_PERMISSIONS, PERMISSIONS } from '@/lib/auth/permissions'
|
||||||
|
|
||||||
describe('Sprachkonvention', () => {
|
describe('Sprachkonvention', () => {
|
||||||
it('haelt die Stufenbezeichnungen aus den Texten heraus', () => {
|
it('haelt die Stufenbezeichnungen aus den Texten heraus', () => {
|
||||||
@@ -19,9 +20,21 @@ describe('Sprachkonvention', () => {
|
|||||||
it('haelt deutsche Oberflaechentexte aus dem JSX heraus', () => {
|
it('haelt deutsche Oberflaechentexte aus dem JSX heraus', () => {
|
||||||
// Umlaute und Eszett sind der billigste verlaessliche Marker fuer einen
|
// Umlaute und Eszett sind der billigste verlaessliche Marker fuer einen
|
||||||
// deutschen Text. Alles Sichtbare gehoert nach lib/i18n/de.ts, siehe CLAUDE.md.
|
// deutschen Text. Alles Sichtbare gehoert nach lib/i18n/de.ts, siehe CLAUDE.md.
|
||||||
|
//
|
||||||
|
// Geprueft werden Komponenten und Seiten (.tsx) sowie Server Actions:
|
||||||
|
// deren Fehlermeldungen landen in der Oberflaeche. Kommentare zaehlen
|
||||||
|
// nicht, die duerfen und sollen deutsch sein.
|
||||||
const offenders: string[] = []
|
const offenders: string[] = []
|
||||||
const roots = ['app', 'components']
|
const roots = ['app', 'components']
|
||||||
|
|
||||||
|
function stripComments(source: string): string {
|
||||||
|
// Blockkommentare durch gleich viele Zeilenumbrueche ersetzen, damit die
|
||||||
|
// Zeilennummern stimmen bleiben.
|
||||||
|
return source
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, (match) => match.replace(/[^\n]/g, ' '))
|
||||||
|
.replace(/\/\/.*$/gm, '')
|
||||||
|
}
|
||||||
|
|
||||||
function walk(dir: string) {
|
function walk(dir: string) {
|
||||||
for (const name of readdirSync(dir)) {
|
for (const name of readdirSync(dir)) {
|
||||||
const path = join(dir, name)
|
const path = join(dir, name)
|
||||||
@@ -29,11 +42,10 @@ describe('Sprachkonvention', () => {
|
|||||||
walk(path)
|
walk(path)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!name.endsWith('.tsx')) continue
|
if (!name.endsWith('.tsx') && name !== 'actions.ts') continue
|
||||||
const source = readFileSync(path, 'utf8')
|
const source = stripComments(readFileSync(path, 'utf8'))
|
||||||
for (const [index, line] of source.split('\n').entries()) {
|
for (const [index, line] of source.split('\n').entries()) {
|
||||||
const withoutComments = line.replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, '')
|
if (/[äöüÄÖÜß]/.test(line)) {
|
||||||
if (/[äöüÄÖÜß]/.test(withoutComments)) {
|
|
||||||
offenders.push(`${path}:${index + 1}`)
|
offenders.push(`${path}:${index + 1}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,3 +56,25 @@ describe('Sprachkonvention', () => {
|
|||||||
expect(offenders).toEqual([])
|
expect(offenders).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Rechte-Enum', () => {
|
||||||
|
it('kennt die vier Rechte, die Phase 1 anlegen muss', () => {
|
||||||
|
for (const permission of [
|
||||||
|
'entry.edit_own',
|
||||||
|
'entry.edit_any',
|
||||||
|
'entry.retract',
|
||||||
|
'case.edit_closed',
|
||||||
|
]) {
|
||||||
|
expect(PERMISSIONS).toContain(permission)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('haelt drei davon im Auslieferungszustand zurueck', () => {
|
||||||
|
// Der Leiter schaltet sie bewusst frei, siehe docs/03-auth-und-rechte.md.
|
||||||
|
expect([...OPT_IN_PERMISSIONS].sort()).toEqual([
|
||||||
|
'entry.edit_any',
|
||||||
|
'entry.edit_own',
|
||||||
|
'entry.retract',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { createPrismaClient, loadEnvFromRepoRoot, type PrismaClient } from '@afis/db'
|
||||||
|
|
||||||
|
loadEnvFromRepoRoot()
|
||||||
|
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
|
||||||
|
const suite = url ? describe : describe.skip
|
||||||
|
|
||||||
|
let db: PrismaClient
|
||||||
|
const marke = `rechte-${Date.now()}`
|
||||||
|
|
||||||
|
const rollen = {
|
||||||
|
afis: `${marke}-rolle-afis`,
|
||||||
|
gast: `${marke}-rolle-gast`,
|
||||||
|
agent: `${marke}-rolle-agent`,
|
||||||
|
leitung: `${marke}-rolle-leitung`,
|
||||||
|
zugangHoch: `${marke}-rolle-zugang-hoch`,
|
||||||
|
zugangNiedrig: `${marke}-rolle-zugang-niedrig`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const nutzer = {
|
||||||
|
mitglied: `${marke}-mitglied`,
|
||||||
|
gast: `${marke}-gast`,
|
||||||
|
notfallAdmin: `${marke}-notfall`,
|
||||||
|
}
|
||||||
|
|
||||||
|
suite('Rechteauflösung', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = createPrismaClient(url)
|
||||||
|
|
||||||
|
await db.roleMapping.createMany({
|
||||||
|
data: [
|
||||||
|
{ discordRoleId: rollen.afis, label: 'AfiS', category: 'MEMBERSHIP', grantsMembership: true },
|
||||||
|
{ discordRoleId: rollen.gast, label: 'Gast', category: 'UNSORTED' },
|
||||||
|
{
|
||||||
|
discordRoleId: rollen.agent,
|
||||||
|
label: 'Agent',
|
||||||
|
category: 'RANK',
|
||||||
|
permissions: ['case.read', 'entry.create'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
discordRoleId: rollen.leitung,
|
||||||
|
label: 'Section Commander',
|
||||||
|
category: 'RANK',
|
||||||
|
permissions: ['case.read', 'case.close', 'export.approve'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
discordRoleId: rollen.zugangNiedrig,
|
||||||
|
label: 'Restricted Access',
|
||||||
|
category: 'CLEARANCE',
|
||||||
|
clearanceLevel: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
discordRoleId: rollen.zugangHoch,
|
||||||
|
label: 'Critical Operations',
|
||||||
|
category: 'CLEARANCE',
|
||||||
|
clearanceLevel: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const id of Object.values(nutzer)) {
|
||||||
|
await db.user.create({ data: { id, username: id, displayName: id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.userRole.createMany({
|
||||||
|
data: [
|
||||||
|
{ userId: nutzer.mitglied, discordRoleId: rollen.afis },
|
||||||
|
{ userId: nutzer.mitglied, discordRoleId: rollen.agent },
|
||||||
|
{ userId: nutzer.mitglied, discordRoleId: rollen.leitung },
|
||||||
|
{ userId: nutzer.mitglied, discordRoleId: rollen.zugangNiedrig },
|
||||||
|
{ userId: nutzer.mitglied, discordRoleId: rollen.zugangHoch },
|
||||||
|
{ userId: nutzer.gast, discordRoleId: rollen.gast },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.$disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { invalidateAll } = await import('@/lib/auth/effective')
|
||||||
|
invalidateAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nimmt die höchste Stufe und die Vereinigung der Rechte', async () => {
|
||||||
|
const { resolvePermissions } = await import('@/lib/auth/effective')
|
||||||
|
const p = await resolvePermissions(nutzer.mitglied)
|
||||||
|
|
||||||
|
expect(p.clearanceLevel).toBe(4)
|
||||||
|
expect([...p.permissions].sort()).toEqual([
|
||||||
|
'case.close',
|
||||||
|
'case.read',
|
||||||
|
'entry.create',
|
||||||
|
'export.approve',
|
||||||
|
])
|
||||||
|
expect(p.hasMembership).toBe(true)
|
||||||
|
expect(p.isAdmin).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erkennt einen Gast ohne AfiS-Rolle als nicht zugangsberechtigt', async () => {
|
||||||
|
const { resolvePermissions } = await import('@/lib/auth/effective')
|
||||||
|
const p = await resolvePermissions(nutzer.gast)
|
||||||
|
|
||||||
|
expect(p.hasMembership).toBe(false)
|
||||||
|
expect(p.clearanceLevel).toBe(0)
|
||||||
|
expect(p.permissions.size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gibt dem Notfallzugang Stufe 5 und alle Rechte ohne Rollen', async () => {
|
||||||
|
process.env.BOOTSTRAP_ADMIN_IDS = nutzer.notfallAdmin
|
||||||
|
const { resetEnvCache } = await import('@/lib/env')
|
||||||
|
resetEnvCache()
|
||||||
|
|
||||||
|
const { resolvePermissions, invalidateAll } = await import('@/lib/auth/effective')
|
||||||
|
invalidateAll()
|
||||||
|
const p = await resolvePermissions(nutzer.notfallAdmin)
|
||||||
|
|
||||||
|
expect(p.isAdmin).toBe(true)
|
||||||
|
expect(p.clearanceLevel).toBe(5)
|
||||||
|
expect(p.hasMembership).toBe(true)
|
||||||
|
expect(p.permissions.has('roles.manage')).toBe(true)
|
||||||
|
expect(p.permissions.has('entry.retract')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verwirft den Cache, wenn sich das Rollen-Mapping ändert', async () => {
|
||||||
|
const { resolvePermissions, invalidateAll } = await import('@/lib/auth/effective')
|
||||||
|
|
||||||
|
expect((await resolvePermissions(nutzer.mitglied)).clearanceLevel).toBe(4)
|
||||||
|
|
||||||
|
await db.roleMapping.update({
|
||||||
|
where: { discordRoleId: rollen.zugangHoch },
|
||||||
|
data: { clearanceLevel: 2 },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ohne Verwerfen liefert der Cache weiterhin die alte Stufe.
|
||||||
|
expect((await resolvePermissions(nutzer.mitglied)).clearanceLevel).toBe(4)
|
||||||
|
|
||||||
|
invalidateAll()
|
||||||
|
expect((await resolvePermissions(nutzer.mitglied)).clearanceLevel).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { createPrismaClient, loadEnvFromRepoRoot, type PrismaClient } from '@afis/db'
|
||||||
|
import { visibleCaseWhere, visibleEntryWhere } from '@/lib/access/caseFilter'
|
||||||
|
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Negativtests aus docs/03-auth-und-rechte.md.
|
||||||
|
*
|
||||||
|
* Sie laufen gegen eine echte Datenbank, weil genau das der Punkt ist: die
|
||||||
|
* Prüfung muss in der Abfrage stecken, nicht in einem Filter danach. Ein Test
|
||||||
|
* gegen eine ausgedachte Liste würde die Aussage nicht treffen.
|
||||||
|
*/
|
||||||
|
loadEnvFromRepoRoot()
|
||||||
|
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
|
||||||
|
const suite = url ? describe : describe.skip
|
||||||
|
|
||||||
|
let db: PrismaClient
|
||||||
|
const marke = `zugriff-${Date.now()}`
|
||||||
|
|
||||||
|
function perms(
|
||||||
|
userId: string,
|
||||||
|
clearanceLevel: number,
|
||||||
|
options: { isAdmin?: boolean; permissions?: Permission[] } = {},
|
||||||
|
): EffectivePermissions {
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
clearanceLevel,
|
||||||
|
permissions: new Set<Permission>(options.permissions ?? []),
|
||||||
|
isAdmin: options.isAdmin ?? false,
|
||||||
|
hasMembership: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suite('Sichtbarkeit von Akten', () => {
|
||||||
|
const ids = {
|
||||||
|
offen: `${marke}-offen`,
|
||||||
|
geheim: `${marke}-geheim`,
|
||||||
|
versiegelt: `${marke}-versiegelt`,
|
||||||
|
}
|
||||||
|
const nutzer = {
|
||||||
|
stufe1: `${marke}-stufe1`,
|
||||||
|
stufe2: `${marke}-stufe2`,
|
||||||
|
stufe5: `${marke}-stufe5`,
|
||||||
|
beteiligt: `${marke}-beteiligt`,
|
||||||
|
admin: `${marke}-admin`,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = createPrismaClient(url)
|
||||||
|
|
||||||
|
const template = await db.caseTemplate.create({
|
||||||
|
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const id of Object.values(nutzer)) {
|
||||||
|
await db.user.create({ data: { id, username: id, displayName: id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const gemeinsam = { templateId: template.id, templateKey: template.key, createdById: nutzer.stufe5 }
|
||||||
|
|
||||||
|
await db.case.create({
|
||||||
|
data: {
|
||||||
|
...gemeinsam,
|
||||||
|
id: ids.offen,
|
||||||
|
caseNumber: `AfiS-9001-${String(Date.now()).slice(-4)}`,
|
||||||
|
title: 'Offener Vorgang',
|
||||||
|
classification: 1,
|
||||||
|
status: 'OPEN',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.case.create({
|
||||||
|
data: {
|
||||||
|
...gemeinsam,
|
||||||
|
id: ids.geheim,
|
||||||
|
caseNumber: `AfiS-9002-${String(Date.now()).slice(-4)}`,
|
||||||
|
title: 'Geheimer Vorgang',
|
||||||
|
classification: 4,
|
||||||
|
status: 'OPEN',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.case.create({
|
||||||
|
data: {
|
||||||
|
...gemeinsam,
|
||||||
|
id: ids.versiegelt,
|
||||||
|
caseNumber: `AfiS-9003-${String(Date.now()).slice(-4)}`,
|
||||||
|
title: 'Versiegelter Vorgang',
|
||||||
|
classification: 1,
|
||||||
|
status: 'SEALED',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Beteiligter an der geheimen Akte, obwohl seine Stufe nicht reicht.
|
||||||
|
await db.caseParticipant.create({
|
||||||
|
data: { caseId: ids.geheim, userId: nutzer.beteiligt, role: 'WITNESS' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ausgeschlossen von der offenen Akte, obwohl Stufe 5 und Admin.
|
||||||
|
await db.caseExclusion.createMany({
|
||||||
|
data: [
|
||||||
|
{ caseId: ids.offen, userId: nutzer.stufe5, reason: 'Betroffener' },
|
||||||
|
{ caseId: ids.offen, userId: nutzer.admin, reason: 'Betroffener' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.$disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function sichtbar(p: EffectivePermissions): Promise<string[]> {
|
||||||
|
const rows = await db.case.findMany({
|
||||||
|
where: { AND: [visibleCaseWhere(p), { id: { in: Object.values(ids) } }] },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
return rows.map((r) => r.id).sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
it('zeigt einer Stufe 2 die Stufe-4-Akte nicht, auch nicht als Treffer', async () => {
|
||||||
|
const p = perms(nutzer.stufe2, 2)
|
||||||
|
const treffer = await sichtbar(p)
|
||||||
|
expect(treffer).not.toContain(ids.geheim)
|
||||||
|
expect(treffer).toContain(ids.offen)
|
||||||
|
|
||||||
|
// Auch die Zählung darf sie nicht enthalten, sonst verrät die Lücke sie.
|
||||||
|
const anzahl = await db.case.count({
|
||||||
|
where: { AND: [visibleCaseWhere(p), { id: { in: Object.values(ids) } }] },
|
||||||
|
})
|
||||||
|
expect(anzahl).toBe(treffer.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gibt einem ausgeschlossenen Nutzer mit Stufe 5 keinen Zugriff', async () => {
|
||||||
|
const treffer = await sichtbar(perms(nutzer.stufe5, 5))
|
||||||
|
expect(treffer).not.toContain(ids.offen)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schlägt selbst den Admin, wenn er ausgeschlossen ist', async () => {
|
||||||
|
// Der Ausschluss deckt den Fall ab, dass gegen einen hochrangigen
|
||||||
|
// Mitarbeiter ermittelt wird. Der Administrator ist einer.
|
||||||
|
const treffer = await sichtbar(perms(nutzer.admin, 5, { isAdmin: true }))
|
||||||
|
expect(treffer).not.toContain(ids.offen)
|
||||||
|
expect(treffer).toContain(ids.geheim)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt einen Beteiligten auch über seiner Stufe lesen', async () => {
|
||||||
|
const treffer = await sichtbar(perms(nutzer.beteiligt, 1))
|
||||||
|
expect(treffer).toContain(ids.geheim)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verbirgt versiegelte Akten unterhalb von Stufe 5', async () => {
|
||||||
|
expect(await sichtbar(perms(nutzer.stufe2, 2))).not.toContain(ids.versiegelt)
|
||||||
|
expect(await sichtbar(perms(nutzer.stufe1, 1))).not.toContain(ids.versiegelt)
|
||||||
|
expect(await sichtbar(perms(nutzer.stufe5, 5))).toContain(ids.versiegelt)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verbirgt Verlaufseinträge oberhalb der Stufe des Lesers', async () => {
|
||||||
|
await db.caseEntry.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: `${marke}-eintrag-offen`,
|
||||||
|
caseId: ids.offen,
|
||||||
|
type: 'NOTE',
|
||||||
|
body: 'Für alle lesbar',
|
||||||
|
authorId: nutzer.stufe1,
|
||||||
|
minClearance: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${marke}-eintrag-hoch`,
|
||||||
|
caseId: ids.offen,
|
||||||
|
type: 'NOTE',
|
||||||
|
body: 'Nur ab Stufe 4',
|
||||||
|
authorId: nutzer.stufe1,
|
||||||
|
minClearance: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const p = perms(nutzer.stufe1, 1)
|
||||||
|
const eintraege = await db.caseEntry.findMany({
|
||||||
|
where: { AND: [visibleEntryWhere(p), { caseId: ids.offen }] },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
const gefunden = eintraege.map((e) => e.id)
|
||||||
|
expect(gefunden).toContain(`${marke}-eintrag-offen`)
|
||||||
|
expect(gefunden).not.toContain(`${marke}-eintrag-hoch`)
|
||||||
|
})
|
||||||
|
})
|
||||||
+83
-9
@@ -1,15 +1,18 @@
|
|||||||
import { Client, GatewayIntentBits, Events } from 'discord.js'
|
import { Client, Events, GatewayIntentBits, type Guild } from 'discord.js'
|
||||||
import { createPrismaClient } from '@afis/db'
|
import { createPrismaClient } from '@afis/db'
|
||||||
import { loadEnv } from './env.js'
|
import { loadEnv } from './env.js'
|
||||||
import { writeHeartbeat } from './heartbeat.js'
|
import { writeHeartbeat } from './heartbeat.js'
|
||||||
|
import { enforceMembership, handleDeparture, syncMember, syncRoles } from './sync.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rollen-Abgleich-Worker. Haengt am Discord-Gateway und haelt die
|
* Rollen-Abgleich-Worker. Hängt am Discord-Gateway und hält Guild-Rollen und
|
||||||
* Guild-Mitgliedschaft in Postgres aktuell, damit die Web-App nie live bei
|
* Mitgliedschaft in Postgres aktuell, damit die Web-App nie live bei Discord
|
||||||
* Discord anfragen muss. Siehe docs/02-architektur.md.
|
* anfragen muss. Siehe docs/02-architektur.md.
|
||||||
*
|
*
|
||||||
* Phase 0 baut das Geruest: Verbindung, Lebenszeichen, sauberes Herunterfahren.
|
* Zwei Wege, absichtlich doppelt:
|
||||||
* Der eigentliche Abgleich (Ereignisse, Voll-Abgleich, pg_notify) kommt in Phase 1.
|
* - Gateway-Ereignisse, damit eine Änderung in unter einer Sekunde ankommt
|
||||||
|
* - Voll-Abgleich alle fünf Minuten als Sicherheitsnetz, falls ein Ereignis
|
||||||
|
* verloren geht oder der Prozess eine Weile weg war
|
||||||
*/
|
*/
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const env = loadEnv()
|
const env = loadEnv()
|
||||||
@@ -23,11 +26,73 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
let gatewayReady = false
|
let gatewayReady = false
|
||||||
|
|
||||||
|
async function fullSync(reason: string): Promise<void> {
|
||||||
|
const guild = await client.guilds.fetch(env.discordGuildId)
|
||||||
|
const roleCount = await syncRoles(db, await guild.fetch())
|
||||||
|
const members = await guild.members.fetch()
|
||||||
|
|
||||||
|
let changed = 0
|
||||||
|
for (const member of members.values()) {
|
||||||
|
if (member.user.bot) continue
|
||||||
|
if (await syncMember(db, member)) changed++
|
||||||
|
await enforceMembership(db, member.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wer nicht mehr auf dem Server ist, hat auch keine Sitzung mehr.
|
||||||
|
const known = await db.user.findMany({ where: { isActive: true }, select: { id: true } })
|
||||||
|
for (const user of known) {
|
||||||
|
if (!members.has(user.id)) await handleDeparture(db, user.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[worker] Voll-Abgleich (${reason}): ${roleCount} Rollen, ${members.size} Mitglieder, ${changed} geändert`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
client.once(Events.ClientReady, (ready) => {
|
client.once(Events.ClientReady, (ready) => {
|
||||||
gatewayReady = true
|
gatewayReady = true
|
||||||
console.log(`[worker] am Gateway als ${ready.user.tag}`)
|
console.log(`[worker] am Gateway als ${ready.user.tag}`)
|
||||||
|
void fullSync('Start').catch((error: unknown) =>
|
||||||
|
console.error('[worker] erster Abgleich fehlgeschlagen', error),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
client.on(Events.GuildMemberAdd, (member) => {
|
||||||
|
if (member.guild.id !== env.discordGuildId || member.user.bot) return
|
||||||
|
void (async () => {
|
||||||
|
await syncMember(db, member)
|
||||||
|
await enforceMembership(db, member.id)
|
||||||
|
})().catch((error: unknown) => console.error('[worker] guildMemberAdd', error))
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on(Events.GuildMemberUpdate, (_before, member) => {
|
||||||
|
if (member.guild.id !== env.discordGuildId || member.user.bot) return
|
||||||
|
void (async () => {
|
||||||
|
await syncMember(db, member)
|
||||||
|
await enforceMembership(db, member.id)
|
||||||
|
})().catch((error: unknown) => console.error('[worker] guildMemberUpdate', error))
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on(Events.GuildMemberRemove, (member) => {
|
||||||
|
if (member.guild.id !== env.discordGuildId) return
|
||||||
|
void handleDeparture(db, member.id).catch((error: unknown) =>
|
||||||
|
console.error('[worker] guildMemberRemove', error),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wird eine Rolle umbenannt, verschoben oder gelöscht, muss der Adminbereich
|
||||||
|
// das sehen. Der Trigger auf RoleMapping löst danach pg_notify aus.
|
||||||
|
const onRoleChange = (guild: Guild): void => {
|
||||||
|
if (guild.id !== env.discordGuildId) return
|
||||||
|
void syncRoles(db, guild).catch((error: unknown) =>
|
||||||
|
console.error('[worker] Rollenabgleich', error),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.on(Events.GuildRoleCreate, (role) => onRoleChange(role.guild))
|
||||||
|
client.on(Events.GuildRoleUpdate, (_before, role) => onRoleChange(role.guild))
|
||||||
|
client.on(Events.GuildRoleDelete, (role) => onRoleChange(role.guild))
|
||||||
|
|
||||||
client.on(Events.ShardDisconnect, (_event, shardId) => {
|
client.on(Events.ShardDisconnect, (_event, shardId) => {
|
||||||
gatewayReady = false
|
gatewayReady = false
|
||||||
console.warn(`[worker] Verbindung zu Shard ${shardId} verloren`)
|
console.warn(`[worker] Verbindung zu Shard ${shardId} verloren`)
|
||||||
@@ -36,11 +101,12 @@ async function main(): Promise<void> {
|
|||||||
client.on(Events.ShardResume, () => {
|
client.on(Events.ShardResume, () => {
|
||||||
gatewayReady = true
|
gatewayReady = true
|
||||||
console.log('[worker] Verbindung wiederhergestellt')
|
console.log('[worker] Verbindung wiederhergestellt')
|
||||||
|
void fullSync('nach Wiederverbindung').catch((error: unknown) =>
|
||||||
|
console.error('[worker] Abgleich nach Wiederverbindung fehlgeschlagen', error),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
client.on(Events.Error, (error) => {
|
client.on(Events.Error, (error) => console.error('[worker] Gateway-Fehler', error))
|
||||||
console.error('[worker] Gateway-Fehler', error)
|
|
||||||
})
|
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
void writeHeartbeat(
|
void writeHeartbeat(
|
||||||
@@ -50,9 +116,17 @@ async function main(): Promise<void> {
|
|||||||
).catch((error: unknown) => console.error('[worker] Lebenszeichen fehlgeschlagen', error))
|
).catch((error: unknown) => console.error('[worker] Lebenszeichen fehlgeschlagen', error))
|
||||||
}, env.heartbeatIntervalMs)
|
}, env.heartbeatIntervalMs)
|
||||||
|
|
||||||
|
const periodicSync = setInterval(() => {
|
||||||
|
if (!gatewayReady) return
|
||||||
|
void fullSync('planmäßig').catch((error: unknown) =>
|
||||||
|
console.error('[worker] Voll-Abgleich fehlgeschlagen', error),
|
||||||
|
)
|
||||||
|
}, env.fullSyncIntervalMs)
|
||||||
|
|
||||||
async function shutdown(signal: string): Promise<void> {
|
async function shutdown(signal: string): Promise<void> {
|
||||||
console.log(`[worker] ${signal}, fahre herunter`)
|
console.log(`[worker] ${signal}, fahre herunter`)
|
||||||
clearInterval(heartbeat)
|
clearInterval(heartbeat)
|
||||||
|
clearInterval(periodicSync)
|
||||||
await writeHeartbeat(db, 'down', 'planmäßig beendet').catch(() => undefined)
|
await writeHeartbeat(db, 'down', 'planmäßig beendet').catch(() => undefined)
|
||||||
await client.destroy()
|
await client.destroy()
|
||||||
await db.$disconnect()
|
await db.$disconnect()
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import type { Guild, GuildMember, Role } from 'discord.js'
|
||||||
|
import type { PrismaClient } from '@afis/db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spiegelt Guild und Mitglieder nach Postgres.
|
||||||
|
*
|
||||||
|
* Die Web-App fragt nie live bei Discord an. Dadurch ist eine Rechteänderung
|
||||||
|
* in Discord in unter einer Sekunde in der App wirksam, ohne dass ein
|
||||||
|
* einziger Request auf die Discord-API wartet. Siehe docs/02-architektur.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Rollen, die Discord selbst verwaltet und die niemandem etwas nützen. */
|
||||||
|
function isRelevantRole(role: Role): boolean {
|
||||||
|
return !role.managed && role.id !== role.guild.id // @everyone hat die Guild-ID
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt für jede Rolle der Guild eine Zeile in `RoleMapping` an, damit im
|
||||||
|
* Adminbereich nichts von Hand eingetragen werden muss. Vorhandene
|
||||||
|
* Zuordnungen bleiben unangetastet: gespiegelt werden nur Name und Position.
|
||||||
|
*
|
||||||
|
* Rollen, die es nicht mehr gibt, werden nicht gelöscht, sondern auf
|
||||||
|
* `isPresent = false` gesetzt. Beim Umzug auf einen anderen Discord-Server
|
||||||
|
* bleiben sie damit als Vorlage stehen. Siehe docs/13-entscheidungen.md.
|
||||||
|
*/
|
||||||
|
export async function syncRoles(db: PrismaClient, guild: Guild): Promise<number> {
|
||||||
|
const roles = [...guild.roles.cache.values()].filter(isRelevantRole)
|
||||||
|
|
||||||
|
for (const role of roles) {
|
||||||
|
await db.roleMapping.upsert({
|
||||||
|
where: { discordRoleId: role.id },
|
||||||
|
create: {
|
||||||
|
discordRoleId: role.id,
|
||||||
|
label: role.name,
|
||||||
|
sortOrder: role.position,
|
||||||
|
isPresent: true,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
label: role.name,
|
||||||
|
sortOrder: role.position,
|
||||||
|
isPresent: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.roleMapping.updateMany({
|
||||||
|
where: { discordRoleId: { notIn: roles.map((r) => r.id) }, isPresent: true },
|
||||||
|
data: { isPresent: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
return roles.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt Rollen eines Mitglieds. Gibt zurück, ob sich etwas geändert hat,
|
||||||
|
* damit nur dann `pg_notify` ausgelöst wird — der Voll-Abgleich läuft alle
|
||||||
|
* fünf Minuten und soll nicht jedes Mal alle Caches leeren.
|
||||||
|
*
|
||||||
|
* `pg_notify` selbst kommt aus dem Trigger `user_role_notify` auf `UserRole`,
|
||||||
|
* es muss hier nichts von Hand gesendet werden.
|
||||||
|
*/
|
||||||
|
export async function syncMember(db: PrismaClient, member: GuildMember): Promise<boolean> {
|
||||||
|
const roleIds = [...member.roles.cache.keys()].filter((id) => id !== member.guild.id)
|
||||||
|
const displayName = member.nickname ?? member.user.globalName ?? member.user.username
|
||||||
|
|
||||||
|
const [existingUser, existingRoles] = await Promise.all([
|
||||||
|
db.user.findUnique({
|
||||||
|
where: { id: member.id },
|
||||||
|
select: { displayName: true, username: true, avatarHash: true, isActive: true },
|
||||||
|
}),
|
||||||
|
db.userRole.findMany({ where: { userId: member.id }, select: { discordRoleId: true } }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const before = new Set(existingRoles.map((r) => r.discordRoleId))
|
||||||
|
const after = new Set(roleIds)
|
||||||
|
const rolesChanged =
|
||||||
|
before.size !== after.size || [...after].some((id) => !before.has(id))
|
||||||
|
|
||||||
|
const profileChanged =
|
||||||
|
!existingUser ||
|
||||||
|
existingUser.displayName !== displayName ||
|
||||||
|
existingUser.username !== member.user.username ||
|
||||||
|
existingUser.avatarHash !== member.user.avatar ||
|
||||||
|
!existingUser.isActive
|
||||||
|
|
||||||
|
if (!rolesChanged && !profileChanged) return false
|
||||||
|
|
||||||
|
await db.$transaction([
|
||||||
|
db.user.upsert({
|
||||||
|
where: { id: member.id },
|
||||||
|
create: {
|
||||||
|
id: member.id,
|
||||||
|
username: member.user.username,
|
||||||
|
displayName,
|
||||||
|
avatarHash: member.user.avatar,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
username: member.user.username,
|
||||||
|
displayName,
|
||||||
|
avatarHash: member.user.avatar,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.userRole.deleteMany({ where: { userId: member.id, discordRoleId: { notIn: roleIds } } }),
|
||||||
|
...roleIds.map((discordRoleId) =>
|
||||||
|
db.userRole.upsert({
|
||||||
|
where: { userId_discordRoleId: { userId: member.id, discordRoleId } },
|
||||||
|
create: { userId: member.id, discordRoleId },
|
||||||
|
update: {},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beendet alle Sitzungen eines Nutzers, wenn er keine Rolle mehr trägt, die
|
||||||
|
* zur Anmeldung berechtigt. Der Datensatz bleibt stehen, damit Autorenangaben
|
||||||
|
* in alten Akten gültig bleiben. Siehe docs/03-auth-und-rechte.md.
|
||||||
|
*
|
||||||
|
* Das passiert hier und nicht in der Web-App, weil es sonst erst beim nächsten
|
||||||
|
* Request des Betroffenen griffe — und genau darauf kann man sich nicht
|
||||||
|
* verlassen, wenn jemandem gerade der Zugriff entzogen wurde.
|
||||||
|
*/
|
||||||
|
export async function enforceMembership(db: PrismaClient, userId: string): Promise<boolean> {
|
||||||
|
const roles = await db.userRole.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { discordRoleId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const granting = await db.roleMapping.count({
|
||||||
|
where: {
|
||||||
|
discordRoleId: { in: roles.map((r) => r.discordRoleId) },
|
||||||
|
grantsMembership: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (granting > 0) return false
|
||||||
|
|
||||||
|
const [{ count }] = await Promise.all([
|
||||||
|
db.session.deleteMany({ where: { userId } }),
|
||||||
|
db.user.update({ where: { id: userId }, data: { isActive: false } }).catch(() => undefined),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
console.log(`[worker] ${count} Sitzung(en) von ${userId} beendet, keine AfiS-Rolle mehr`)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serveraustritt: Sitzungen beenden, Datensatz behalten. */
|
||||||
|
export async function handleDeparture(db: PrismaClient, userId: string): Promise<void> {
|
||||||
|
await db.$transaction([
|
||||||
|
db.session.deleteMany({ where: { userId } }),
|
||||||
|
db.userRole.deleteMany({ where: { userId } }),
|
||||||
|
])
|
||||||
|
await db.user.update({ where: { id: userId }, data: { isActive: false } }).catch(() => undefined)
|
||||||
|
console.log(`[worker] ${userId} hat den Server verlassen, Sitzungen beendet`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { createPrismaClient, type PrismaClient } from '@afis/db'
|
||||||
|
import { enforceMembership, handleDeparture } from '../src/sync.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Rollenentzug in Discord beendet laufende Sitzungen" aus
|
||||||
|
* docs/03-auth-und-rechte.md.
|
||||||
|
*
|
||||||
|
* Das Zeitbudget von fünf Sekunden ist im Betrieb die Summe aus
|
||||||
|
* Gateway-Ereignis und diesem Schritt. Geprüft wird hier der Schritt, der in
|
||||||
|
* unserer Hand liegt: die Sitzungen müssen weg sein, und der Datensatz muss
|
||||||
|
* stehen bleiben, damit Autorenangaben in alten Akten gültig bleiben.
|
||||||
|
*/
|
||||||
|
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
|
||||||
|
const suite = url ? describe : describe.skip
|
||||||
|
|
||||||
|
let db: PrismaClient
|
||||||
|
const marke = `worker-${Date.now()}`
|
||||||
|
|
||||||
|
const rolleAfis = `${marke}-afis`
|
||||||
|
const rolleGast = `${marke}-gast`
|
||||||
|
|
||||||
|
suite('Sitzungen beim Rollenentzug', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = createPrismaClient(url)
|
||||||
|
await db.roleMapping.createMany({
|
||||||
|
data: [
|
||||||
|
{ discordRoleId: rolleAfis, label: 'AfiS', category: 'MEMBERSHIP', grantsMembership: true },
|
||||||
|
{ discordRoleId: rolleGast, label: 'Gast', category: 'UNSORTED' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.$disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function nutzerMitSitzung(id: string, rollen: string[]): Promise<void> {
|
||||||
|
await db.user.create({ data: { id, username: id, displayName: id } })
|
||||||
|
if (rollen.length > 0) {
|
||||||
|
await db.userRole.createMany({
|
||||||
|
data: rollen.map((discordRoleId) => ({ userId: id, discordRoleId })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await db.session.create({
|
||||||
|
data: { userId: id, expiresAt: new Date(Date.now() + 86_400_000) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lässt eine Sitzung stehen, solange die AfiS-Rolle vorliegt', async () => {
|
||||||
|
const id = `${marke}-bleibt`
|
||||||
|
await nutzerMitSitzung(id, [rolleAfis])
|
||||||
|
|
||||||
|
const beendet = await enforceMembership(db, id)
|
||||||
|
|
||||||
|
expect(beendet).toBe(false)
|
||||||
|
expect(await db.session.count({ where: { userId: id } })).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('beendet alle Sitzungen, sobald die AfiS-Rolle fehlt', async () => {
|
||||||
|
const id = `${marke}-verliert`
|
||||||
|
await nutzerMitSitzung(id, [rolleAfis, rolleGast])
|
||||||
|
await db.session.create({
|
||||||
|
data: { userId: id, expiresAt: new Date(Date.now() + 86_400_000) },
|
||||||
|
})
|
||||||
|
expect(await db.session.count({ where: { userId: id } })).toBe(2)
|
||||||
|
|
||||||
|
// Rollenentzug in Discord, hier direkt nachgestellt.
|
||||||
|
await db.userRole.deleteMany({ where: { userId: id, discordRoleId: rolleAfis } })
|
||||||
|
|
||||||
|
const start = Date.now()
|
||||||
|
const beendet = await enforceMembership(db, id)
|
||||||
|
const dauer = Date.now() - start
|
||||||
|
|
||||||
|
expect(beendet).toBe(true)
|
||||||
|
expect(await db.session.count({ where: { userId: id } })).toBe(0)
|
||||||
|
expect(dauer).toBeLessThan(5000)
|
||||||
|
|
||||||
|
// Der Datensatz bleibt, nur inaktiv.
|
||||||
|
const nutzer = await db.user.findUnique({ where: { id } })
|
||||||
|
expect(nutzer).not.toBeNull()
|
||||||
|
expect(nutzer?.isActive).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('beendet Sitzungen beim Serveraustritt und behält den Nutzer', async () => {
|
||||||
|
const id = `${marke}-austritt`
|
||||||
|
await nutzerMitSitzung(id, [rolleAfis])
|
||||||
|
|
||||||
|
await handleDeparture(db, id)
|
||||||
|
|
||||||
|
expect(await db.session.count({ where: { userId: id } })).toBe(0)
|
||||||
|
expect(await db.userRole.count({ where: { userId: id } })).toBe(0)
|
||||||
|
const nutzer = await db.user.findUnique({ where: { id } })
|
||||||
|
expect(nutzer?.isActive).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { loadEnvFromRepoRoot } from '@afis/db'
|
||||||
|
|
||||||
|
// Damit die Tests die lokale Entwicklungsdatenbank finden. In der CI stehen
|
||||||
|
// die Werte bereits in der Umgebung.
|
||||||
|
loadEnvFromRepoRoot()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
setupFiles: ['./tests/setup.ts'],
|
||||||
|
fileParallelism: false,
|
||||||
|
testTimeout: 20_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user