Schliesst Issue #1. Der Wunsch betraf den Kurz-Link, nicht das Ziel: Er soll sich von Anfang an bestimmen und spaeter aendern lassen. Beim Anlegen gibt es dafuer ein Feld, im Bearbeiten-Dialog laesst er sich aendern. Erlaubt sind Kleinbuchstaben, Ziffern, Bindestriche und „/" als Trenner — damit sind Adressen wie kunde-a/sommer moeglich. Umlaute bleiben draussen, weil sie in Adressen unleserlich kodiert werden; Gross- und Kleinschreibung ist beim Aufrufen egal. Beim Aendern bleibt die vorherige Adresse gueltig. Das ist der Kern: Auf gedruckten Codes steht die alte, und genau deren Bestand ist der Sinn eines dynamischen Codes. Frueher vergebene Adressen stehen im Dialog und lassen sich einzeln freigeben — fuer den Tippfehler, mit dem noch nichts gedruckt ist. Solange eine Adresse gehalten wird, kann sie kein anderer Code uebernehmen; sonst liessen sich fremde gedruckte Codes umlenken. Vergeben und Aendern ist standardmaessig Administratoren vorbehalten. Ein Kurz-Link ist eine oeffentliche Adresse, und wer ihn aendert, kann Aushaenge entwerten. Unter Verwaltung -> Einstellungen laesst sich das fuer Bearbeiter freigeben; ohne das Recht erscheint das Feld gar nicht, Codes anlegen und deren Ziel aendern geht weiterhin. Eigene Aktion in der Policy statt Teil von codes:update, weil sie mehr wiegt. Technisch: /r und /s sind Sammelrouten, weil ein Kurz-Link „/" enthalten darf. Die Eindeutigkeit gilt zusaetzlich auf der kleingeschriebenen Form; die Migration bricht ab, falls der Bestand das nicht zulaesst, statt still Daten zu verlieren. Der bestehende exakte Index bleibt — er traegt den haeufigen Fall, den gescannten Code.
152 lines
5.3 KiB
TypeScript
152 lines
5.3 KiB
TypeScript
import "server-only";
|
|
|
|
import type { Role } from "@/generated/prisma/client";
|
|
import type { Actor } from "@/lib/auth/actor";
|
|
import { QrRequestError } from "@/lib/qr/type-module";
|
|
|
|
/**
|
|
* Sämtliche Berechtigungsregeln der Anwendung — an einer Stelle.
|
|
*
|
|
* Jeder schreibende oder lesende Zugriff geht durch `can` bzw. `require`.
|
|
* Die Oberfläche blendet Knöpfe zwar zusätzlich aus, aber das ist nur
|
|
* Bequemlichkeit: verlassen wird sich ausschließlich auf diese Prüfungen.
|
|
*/
|
|
|
|
export type Action =
|
|
/** Codes einer Gruppe sehen und Bilder herunterladen. */
|
|
| "codes:read"
|
|
/** Dynamische Codes anlegen. */
|
|
| "codes:create"
|
|
/** Ziel, Name oder Gestaltung ändern. */
|
|
| "codes:update"
|
|
/** Codes löschen. */
|
|
| "codes:delete"
|
|
/**
|
|
* Den Kurz-Link selbst vergeben oder ändern.
|
|
*
|
|
* Eigene Aktion und nicht Teil von `codes:update`, weil sie mehr wiegt: Ein
|
|
* Kurz-Link ist eine öffentliche Adresse. Wer ihn ändert, entwertet im
|
|
* Zweifel gedruckte Codes; wer ihn vergibt, besetzt eine Adresse, die
|
|
* jemand anderes gebraucht hätte. Deshalb standardmäßig nur für
|
|
* Administratoren — freigeben lässt es sich in der Verwaltung.
|
|
*/
|
|
| "codes:slug"
|
|
/** Mitglieder und Rollen einer Gruppe verwalten. */
|
|
| "group:manage"
|
|
/** Gruppen, Benutzer und die QR-Typ-Freigaben verwalten. */
|
|
| "instance:manage";
|
|
|
|
export type Scope = {
|
|
groupId?: string;
|
|
/**
|
|
* Die Einstellung aus der Verwaltung, ob Bearbeiter Kurz-Links vergeben
|
|
* dürfen. Wird von der aufrufenden Stelle mitgegeben, damit die Regeln hier
|
|
* ohne Datenbankzugriff auskommen und prüfbar bleiben.
|
|
*/
|
|
editorsMaySetSlug?: boolean;
|
|
};
|
|
|
|
const ROLE_RANK: Record<Role, number> = { VIEWER: 0, EDITOR: 1, ADMIN: 2 };
|
|
|
|
export const ROLE_LABELS: Record<Role, string> = {
|
|
ADMIN: "Administrator",
|
|
EDITOR: "Bearbeiter",
|
|
VIEWER: "Betrachter",
|
|
};
|
|
|
|
export const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
|
ADMIN: "Darf alles in der Gruppe, einschließlich Mitglieder und Rollen.",
|
|
EDITOR: "Darf Codes anlegen, ändern und löschen.",
|
|
VIEWER: "Darf Codes ansehen und herunterladen, aber nichts ändern.",
|
|
};
|
|
|
|
/** Rolle des Actors in einer bestimmten Gruppe, falls vorhanden. */
|
|
export function roleInGroup(actor: Actor, groupId: string): Role | null {
|
|
if (actor.kind !== "user") return null;
|
|
return (
|
|
actor.user.memberships.find((m) => m.groupId === groupId)?.role ?? null
|
|
);
|
|
}
|
|
|
|
/** Alle Gruppen, die der Actor sehen darf. `null` heißt: alle. */
|
|
export function visibleGroupIds(actor: Actor): string[] | null {
|
|
if (actor.kind === "system") return null;
|
|
if (actor.kind === "anonymous") return [];
|
|
if (actor.user.isInstanceAdmin) return null;
|
|
return actor.user.memberships.map((m) => m.groupId);
|
|
}
|
|
|
|
/** Gruppen, in denen der Actor Codes anlegen darf. */
|
|
export function writableGroupIds(actor: Actor): string[] | null {
|
|
if (actor.kind === "system") return null;
|
|
if (actor.kind === "anonymous") return [];
|
|
if (actor.user.isInstanceAdmin) return null;
|
|
return actor.user.memberships
|
|
.filter((m) => ROLE_RANK[m.role] >= ROLE_RANK.EDITOR)
|
|
.map((m) => m.groupId);
|
|
}
|
|
|
|
export function can(actor: Actor, action: Action, scope: Scope = {}): boolean {
|
|
if (actor.kind === "system") return true;
|
|
if (actor.kind === "anonymous") return false;
|
|
|
|
// Der Instanz-Administrator darf alles — er verwaltet die Installation.
|
|
if (actor.user.isInstanceAdmin) return true;
|
|
|
|
if (action === "instance:manage") return false;
|
|
|
|
// Alle übrigen Aktionen beziehen sich auf eine Gruppe.
|
|
if (!scope.groupId) return false;
|
|
const role = roleInGroup(actor, scope.groupId);
|
|
if (!role) return false;
|
|
|
|
switch (action) {
|
|
case "codes:read":
|
|
return true; // jede Rolle in der Gruppe darf lesen
|
|
case "codes:create":
|
|
case "codes:update":
|
|
case "codes:delete":
|
|
return ROLE_RANK[role] >= ROLE_RANK.EDITOR;
|
|
case "codes:slug":
|
|
// Administratoren der Gruppe immer, Bearbeiter nur nach Freigabe.
|
|
if (role === "ADMIN") return true;
|
|
return (
|
|
ROLE_RANK[role] >= ROLE_RANK.EDITOR && scope.editorsMaySetSlug === true
|
|
);
|
|
case "group:manage":
|
|
return role === "ADMIN";
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const DENIED_MESSAGES: Record<Action, string> = {
|
|
"codes:read": "Du hast keinen Zugriff auf die Codes dieser Gruppe.",
|
|
"codes:create":
|
|
"Zum Anlegen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
|
|
"codes:update":
|
|
"Zum Ändern von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
|
|
"codes:delete":
|
|
"Zum Löschen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
|
|
"codes:slug":
|
|
"Den Kurz-Link darf hier nur ein Administrator vergeben oder ändern. Ein Administrator der Installation kann das unter Verwaltung → Einstellungen für Bearbeiter freigeben.",
|
|
"group:manage":
|
|
"Mitglieder und Rollen darf nur ein Administrator der Gruppe verwalten.",
|
|
"instance:manage":
|
|
"Diesen Bereich darf nur ein Administrator der Installation verwalten.",
|
|
};
|
|
|
|
/** Wie `can`, wirft aber mit passendem Status und deutscher Meldung. */
|
|
export function requirePermission(
|
|
actor: Actor,
|
|
action: Action,
|
|
scope: Scope = {},
|
|
): void {
|
|
if (can(actor, action, scope)) return;
|
|
|
|
if (actor.kind === "anonymous") {
|
|
throw new QrRequestError("Bitte melde dich an.", 401);
|
|
}
|
|
throw new QrRequestError(DENIED_MESSAGES[action], 403);
|
|
}
|