Phase B1: Anmeldung, Benutzer, Gruppen und Rollen
Umschaltbar ueber AUTH_ENABLED in der .env. Steht sie auf false, verhaelt
sich alles wie bisher - geprueft mit den 200 bestehenden Pruefungen.
Kern ist eine Actor- und Policy-Schicht: getActor() liefert bei
ausgeschalteter Anmeldung einen system-Actor mit Vollzugriff, sonst den
angemeldeten Benutzer. Dadurch braucht keine einzige Route ein
'if (anmeldungAktiv)' - sie fragen alle nur can() bzw. requirePermission().
- Datenmodell: User, Session, Group, Membership; Codes bekommen Gruppe und
Ersteller. Die Migration legt eine Standardgruppe an und ordnet ihr
bestehende Codes zu, bevor die Spalte verpflichtend wird - sonst
scheiterte sie auf jeder bereits benutzten Installation.
- Passwoerter mit scrypt aus der Node-Standardbibliothek. Bewusst kein
Argon2: das braeuchte ein natives Modul passend zur Container-Plattform.
- Sitzungen in der Datenbank, im Cookie nur ein Zufallstoken; gespeichert
wird dessen SHA-256. Deaktivieren oder ein neues Passwort beendet alle
offenen Sitzungen sofort.
- Aussperrschutz: der letzte aktive Administrator kann sich nicht selbst
entmachten, deaktivieren oder loeschen. Der Schalter liegt in der .env,
nicht in der Oberflaeche - so bleibt der Weg zurueck immer offen.
- Ersteinrichtung legt den ersten Administrator an und schliesst sich danach.
- Die Weiterleitung /r/{slug} bleibt oeffentlich, sonst liesse sich kein
gedruckter Code mehr scannen.
Zwei Fehler dabei gefunden und behoben:
- 'return handleLogin(...)' innerhalb eines try-Blocks gibt das Promise
zurueck, bevor es sich aufloest. Die Ablehnung kam nie im catch an und
wurde zu einem nackten 500er statt einer 401. Jetzt mit await.
- Die Fehlerklassen wurden ueber instanceof erkannt. type-module.ts landet
aber sowohl im Server- als auch im Client-Buendel, es gibt also zwei
Klassenidentitaeten - saubere 400er wurden dadurch still zu 500ern. Die
Erkennung laeuft jetzt ueber eine Eigenschaft.
Geprueft: 51 neue Pruefungen fuer Anmeldung und die vollstaendige
Rollenmatrix, dazu die 200 bestehenden bei ausgeschalteter Anmeldung.
This commit is contained in:
+110
-17
@@ -10,6 +10,90 @@ datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
/// Rolle eines Benutzers innerhalb einer Gruppe.
|
||||
///
|
||||
/// Darüber liegt noch eine Ebene: `User.isInstanceAdmin`. Sie darf Gruppen
|
||||
/// und Benutzer überhaupt erst anlegen — ohne sie gäbe es niemanden, der die
|
||||
/// erste Gruppe erstellt.
|
||||
enum Role {
|
||||
/// Darf zusätzlich Mitglieder und Rollen der Gruppe verwalten.
|
||||
ADMIN
|
||||
/// Darf Codes anlegen, ändern und löschen.
|
||||
EDITOR
|
||||
/// Darf nur lesen und herunterladen.
|
||||
VIEWER
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
/// scrypt-Hash, siehe src/lib/auth/password.ts — nie das Klartextpasswort.
|
||||
passwordHash String
|
||||
/// Darf Gruppen, Benutzer und Domains verwalten.
|
||||
isInstanceAdmin Boolean @default(false)
|
||||
/// Deaktivierte Benutzer können sich nicht anmelden; laufende Sitzungen
|
||||
/// werden beim nächsten Zugriff verworfen.
|
||||
isActive Boolean @default(true)
|
||||
failedLogins Int @default(0)
|
||||
lockedUntil DateTime?
|
||||
lastLoginAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sessions Session[]
|
||||
memberships Membership[]
|
||||
createdCodes QrCode[] @relation("QrCodeCreator")
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
/// Angemeldete Sitzung. In der Datenbank statt in einem signierten Cookie,
|
||||
/// damit sie sich serverseitig beenden lässt — etwa wenn ein Benutzer
|
||||
/// deaktiviert wird.
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
/// SHA-256 des Cookie-Werts. Der Wert selbst steht nirgends in der Datenbank:
|
||||
/// wer sie liest, kann sich damit trotzdem nicht anmelden.
|
||||
tokenHash String @unique
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
expiresAt DateTime
|
||||
lastSeenAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
/// Eine Gruppe bündelt Benutzer und die Codes, die ihnen gehören.
|
||||
model Group {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
memberships Membership[]
|
||||
codes QrCode[]
|
||||
}
|
||||
|
||||
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
|
||||
/// kann in mehreren Gruppen sein — mit unterschiedlicher Rolle je Gruppe.
|
||||
model Membership {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
groupId String
|
||||
role Role
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, groupId])
|
||||
@@index([groupId])
|
||||
}
|
||||
|
||||
/// Freigabe eines QR-Typs durch die Verwaltung.
|
||||
///
|
||||
/// Es gibt nur Zeilen für Typen, die bewusst umgeschaltet wurden — fehlt ein
|
||||
@@ -26,24 +110,33 @@ model QrTypeSetting {
|
||||
/// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und
|
||||
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
|
||||
///
|
||||
/// `type` + `data` sind bewusst generisch gehalten: V1 kennt nur den Typ
|
||||
/// "website" (`data = { url: "https://…" }`), V2-Typen (vCard, WLAN, …)
|
||||
/// legen ihr eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
||||
/// `type` + `data` sind bewusst generisch gehalten: die Typ-Module legen ihr
|
||||
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
||||
model QrCode {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
type String
|
||||
label String?
|
||||
data Json
|
||||
fgColor String @default("#000000")
|
||||
bgColor String @default("#FFFFFF")
|
||||
size Int @default(512)
|
||||
ecLevel String @default("M")
|
||||
margin Int @default(4)
|
||||
scans Int @default(0)
|
||||
lastScanAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
type String
|
||||
label String?
|
||||
data Json
|
||||
fgColor String @default("#000000")
|
||||
bgColor String @default("#FFFFFF")
|
||||
size Int @default(512)
|
||||
ecLevel String @default("M")
|
||||
margin Int @default(4)
|
||||
scans Int @default(0)
|
||||
lastScanAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
/// Jeder Code gehört einer Gruppe — auch bei ausgeschalteter Anmeldung.
|
||||
/// Dann ist es die beim ersten Start angelegte Standardgruppe.
|
||||
groupId String
|
||||
group Group @relation(fields: [groupId], references: [id])
|
||||
/// Wer den Code angelegt hat. Leer für Codes aus der Zeit vor der
|
||||
/// Anmeldung und für Codes, deren Ersteller gelöscht wurde.
|
||||
createdById String?
|
||||
createdBy User? @relation("QrCodeCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([groupId])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user