feat: RBAC Phase 5 - Rollenverwaltung im Frontend
Neue /settings/roles-Seite (Aktiv/Historie, Karten-Grid) zum Anlegen,
Bearbeiten, Deaktivieren und Reaktivieren von Rollen mit einer
Rechte-Matrix (nach Bereich gruppierte Checkboxen, Gruppen-Toggle,
Live-Zaehler), passend zum Backend aus Phase 1-3.
- AuthContext liest Rolle/Rechte jetzt ueber GET /auth/me statt aus
dem JWT (das seit Phase 3 keinen role-Claim mehr traegt); neue
hasPermission()-Hilfsfunktion ersetzt String-Vergleiche gegen "ADMIN".
- Sidebar zeigt Verwaltungs-Eintraege jetzt einzeln nach Berechtigung
(statt Alles-oder-Nichts fuer ADMIN), inkl. neuem "Rollen & Rechte"-
Eintrag.
- Settings-Layout blockt nur noch, wenn gar keine Verwaltungs-
Berechtigung vorhanden ist, statt stur auf die ADMIN-Rolle zu pruefen.
- Benutzerseite: Rollen-Auswahl laedt jetzt aktive Rollen von
GET /roles statt einer festen 5-Werte-Liste; sendet roleId statt
role an POST/PATCH /users.
- Totes jwt.ts (Rolle wurde bislang aus dem Token dekodiert) entfernt.
Live im Browser getestet (Login, Sidebar, Rollen anlegen/bearbeiten/
deaktivieren/reaktivieren, Benutzer-Dialog): dabei einen echten a11y-
Bug gefunden und behoben - Gruppen-Checkboxen fuer mehrwortige
Bereichsnamen ("Wiederkehrende Aufgaben", "Rollen & Rechte") hatten
keinen zugaenglichen Namen, weil die HTML-id ein Leerzeichen enthielt;
jetzt ueber einen Slug erzeugt.
Verifiziert: tsc, lint, build, alle Unit-Tests (14/14) gruen.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
ae53f7889e
commit
335c572dfc
17 files changed
+753
-80
No files matched your search
@@ -3,10 +3,23 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
|
|
||||||
|
const SETTINGS_AREA_PERMISSIONS = [
|
||||||
|
"users:read",
|
||||||
|
"roles:read",
|
||||||
|
"audit:read",
|
||||||
|
"webhooks:read",
|
||||||
|
"automation:read",
|
||||||
|
"task-templates:read",
|
||||||
|
"import:execute",
|
||||||
|
];
|
||||||
|
|
||||||
export default function SettingsLayout({ children }: { children: ReactNode }) {
|
export default function SettingsLayout({ children }: { children: ReactNode }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const hasSettingsAccess = SETTINGS_AREA_PERMISSIONS.some((permission) =>
|
||||||
|
user?.permissions.includes(permission),
|
||||||
|
);
|
||||||
|
|
||||||
if (user?.role !== "ADMIN") {
|
if (!hasSettingsAccess) {
|
||||||
return (
|
return (
|
||||||
<main className="p-6">
|
<main className="p-6">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { createRole } from "@/lib/roles/api";
|
||||||
|
import { fetchPermissionCatalog } from "@/lib/roles/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { PermissionMatrix } from "./permission-matrix";
|
||||||
|
|
||||||
|
function normalizeKey(value: string): string {
|
||||||
|
return value
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^A-Z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateRoleDialog() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [key, setKey] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: catalog } = useQuery({
|
||||||
|
queryKey: ["permission-catalog"],
|
||||||
|
queryFn: fetchPermissionCatalog,
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setKey("");
|
||||||
|
setName("");
|
||||||
|
setDescription("");
|
||||||
|
setSelected(new Set());
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: createRole,
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["roles"] });
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
},
|
||||||
|
onError: () => setError("Rolle konnte nicht angelegt werden."),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
mutation.mutate({
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
description: description || undefined,
|
||||||
|
permissionKeys: Array.from(selected),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (!next) reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button onClick={() => setOpen(true)}>+ Neue Rolle</Button>
|
||||||
|
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Neue Rolle</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="role-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="role-name"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => {
|
||||||
|
setName(event.target.value);
|
||||||
|
setKey((current) =>
|
||||||
|
current === "" || current === normalizeKey(name)
|
||||||
|
? normalizeKey(event.target.value)
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="role-key">Schlüssel</Label>
|
||||||
|
<Input
|
||||||
|
id="role-key"
|
||||||
|
required
|
||||||
|
value={key}
|
||||||
|
onChange={(event) => setKey(normalizeKey(event.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="role-description">Beschreibung</Label>
|
||||||
|
<Textarea
|
||||||
|
id="role-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Rechte</Label>
|
||||||
|
<PermissionMatrix
|
||||||
|
catalog={catalog ?? []}
|
||||||
|
selected={selected}
|
||||||
|
onChange={setSelected}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={mutation.isPending || !key || !name}>
|
||||||
|
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { fetchPermissionCatalog, updateRole } from "@/lib/roles/api";
|
||||||
|
import { RoleSummary } from "@/lib/roles/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { PermissionMatrix } from "./permission-matrix";
|
||||||
|
|
||||||
|
interface EditRoleFormProps {
|
||||||
|
role: RoleSummary;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditRoleForm({ role, onClose }: EditRoleFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [name, setName] = useState(role.name);
|
||||||
|
const [description, setDescription] = useState(role.description ?? "");
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set(role.permissionKeys));
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: catalog } = useQuery({
|
||||||
|
queryKey: ["permission-catalog"],
|
||||||
|
queryFn: fetchPermissionCatalog,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (input: { name: string; description?: string; permissionKeys: string[] }) =>
|
||||||
|
updateRole(role.id, input),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["roles"] });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: () => setError("Rolle konnte nicht gespeichert werden."),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
mutation.mutate({
|
||||||
|
name,
|
||||||
|
description: description || undefined,
|
||||||
|
permissionKeys: Array.from(selected),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="edit-role-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-role-name"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="edit-role-description">Beschreibung</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-role-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Rechte</Label>
|
||||||
|
<PermissionMatrix catalog={catalog ?? []} selected={selected} onChange={setSelected} />
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={mutation.isPending || !name}>
|
||||||
|
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditRoleDialogProps {
|
||||||
|
role: RoleSummary | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditRoleDialog({ role, onClose }: EditRoleDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={role !== null} onOpenChange={(next) => !next && onClose()}>
|
||||||
|
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Rolle bearbeiten{role ? `: ${role.name}` : ""}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{role ? <EditRoleForm key={role.id} role={role} onClose={onClose} /> : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Shield } from "lucide-react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
deactivateRole,
|
||||||
|
fetchPermissionCatalog,
|
||||||
|
fetchRoles,
|
||||||
|
reactivateRole,
|
||||||
|
} from "@/lib/roles/api";
|
||||||
|
import { RoleSummary } from "@/lib/roles/types";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
import { EntityCard } from "@/components/entity-card";
|
||||||
|
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||||
|
import { CreateRoleDialog } from "./create-role-dialog";
|
||||||
|
import { EditRoleDialog } from "./edit-role-dialog";
|
||||||
|
|
||||||
|
export default function RolesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [tab, setTab] = useState<EntityLifecycleTab>("active");
|
||||||
|
const [editingRole, setEditingRole] = useState<RoleSummary | null>(null);
|
||||||
|
|
||||||
|
const { data: roles, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ["roles"],
|
||||||
|
queryFn: fetchRoles,
|
||||||
|
});
|
||||||
|
const { data: catalog } = useQuery({
|
||||||
|
queryKey: ["permission-catalog"],
|
||||||
|
queryFn: fetchPermissionCatalog,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deactivateMutation = useMutation({
|
||||||
|
mutationFn: deactivateRole,
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["roles"] });
|
||||||
|
toast.success("Rolle deaktiviert");
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Rolle konnte nicht deaktiviert werden"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reactivateMutation = useMutation({
|
||||||
|
mutationFn: reactivateRole,
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["roles"] });
|
||||||
|
toast.success("Rolle reaktiviert");
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Rolle konnte nicht reaktiviert werden"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeRoles = (roles ?? []).filter((role) => role.status === "ACTIVE");
|
||||||
|
const historyRoles = (roles ?? []).filter((role) => role.status === "INACTIVE");
|
||||||
|
const visibleRoles = tab === "active" ? activeRoles : historyRoles;
|
||||||
|
const catalogLength = catalog?.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
|
<PageHeader
|
||||||
|
icon={Shield}
|
||||||
|
title="Rollen & Rechte"
|
||||||
|
description="Wer darf auf welcher Seite was tun"
|
||||||
|
gradient="var(--gradient-sky)"
|
||||||
|
actions={<CreateRoleDialog />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActiveHistoryTabs
|
||||||
|
value={tab}
|
||||||
|
onChange={setTab}
|
||||||
|
activeCount={activeRoles.length}
|
||||||
|
historyCount={historyRoles.length}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
|
{isError ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
Rollen konnten nicht geladen werden.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{roles && visibleRoles.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Shield}
|
||||||
|
title={tab === "active" ? "Noch keine Rollen" : "Keine Historie"}
|
||||||
|
description={
|
||||||
|
tab === "active"
|
||||||
|
? "Lege eine Rolle an, um Zugriffe granular zu steuern."
|
||||||
|
: "Deaktivierte Rollen erscheinen hier."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{visibleRoles.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{visibleRoles.map((role) => (
|
||||||
|
<EntityCard
|
||||||
|
key={role.id}
|
||||||
|
icon={Shield}
|
||||||
|
gradient="var(--gradient-sky)"
|
||||||
|
title={role.name}
|
||||||
|
subtitle={role.key}
|
||||||
|
badge={
|
||||||
|
role.isSystem ? (
|
||||||
|
<Badge variant="secondary">System-Rolle</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant={role.status === "ACTIVE" ? "default" : "outline"}>
|
||||||
|
{role.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fields={[
|
||||||
|
{
|
||||||
|
label: "Rechte",
|
||||||
|
value: `${role.permissionKeys.length}/${catalogLength}`,
|
||||||
|
},
|
||||||
|
{ label: "Benutzer", value: role.userCount },
|
||||||
|
]}
|
||||||
|
actions={
|
||||||
|
role.isSystem ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Systemrollen können nicht bearbeitet werden.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditingRole(role)}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
{role.status === "ACTIVE" ? (
|
||||||
|
<ConfirmDialog
|
||||||
|
triggerLabel="Deaktivieren"
|
||||||
|
triggerSize="sm"
|
||||||
|
triggerDisabled={role.userCount > 0 || deactivateMutation.isPending}
|
||||||
|
title="Rolle deaktivieren?"
|
||||||
|
description={
|
||||||
|
role.userCount > 0
|
||||||
|
? `„${role.name}“ ist noch ${role.userCount} Benutzer(n) zugewiesen. Bitte zuerst umverteilen.`
|
||||||
|
: `„${role.name}“ kann keinem Benutzer mehr neu zugewiesen werden. Kann jederzeit reaktiviert werden.`
|
||||||
|
}
|
||||||
|
confirmLabel="Deaktivieren"
|
||||||
|
isPending={deactivateMutation.isPending}
|
||||||
|
onConfirm={() => deactivateMutation.mutate(role.id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={reactivateMutation.isPending}
|
||||||
|
onClick={() => reactivateMutation.mutate(role.id)}
|
||||||
|
>
|
||||||
|
Reaktivieren
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<EditRoleDialog role={editingRole} onClose={() => setEditingRole(null)} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { PermissionCatalogEntry } from "@/lib/roles/types";
|
||||||
|
|
||||||
|
interface PermissionMatrixProps {
|
||||||
|
catalog: PermissionCatalogEntry[];
|
||||||
|
selected: Set<string>;
|
||||||
|
onChange: (next: Set<string>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9äöüß]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupCatalog(
|
||||||
|
catalog: PermissionCatalogEntry[],
|
||||||
|
): { groupDe: string; entries: PermissionCatalogEntry[] }[] {
|
||||||
|
const groups = new Map<string, PermissionCatalogEntry[]>();
|
||||||
|
for (const entry of catalog) {
|
||||||
|
const bucket = groups.get(entry.groupDe) ?? [];
|
||||||
|
bucket.push(entry);
|
||||||
|
groups.set(entry.groupDe, bucket);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries())
|
||||||
|
.map(([groupDe, entries]) => ({
|
||||||
|
groupDe,
|
||||||
|
entries: entries.sort((a, b) => a.sortOrder - b.sortOrder),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.entries[0].sortOrder - b.entries[0].sortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PermissionMatrix({
|
||||||
|
catalog,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: PermissionMatrixProps) {
|
||||||
|
const groups = useMemo(() => groupCatalog(catalog), [catalog]);
|
||||||
|
|
||||||
|
function toggleOne(key: string, checked: boolean) {
|
||||||
|
const next = new Set(selected);
|
||||||
|
if (checked) {
|
||||||
|
next.add(key);
|
||||||
|
} else {
|
||||||
|
next.delete(key);
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroup(entries: PermissionCatalogEntry[], checked: boolean) {
|
||||||
|
const next = new Set(selected);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (checked) {
|
||||||
|
next.add(entry.key);
|
||||||
|
} else {
|
||||||
|
next.delete(entry.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 rounded-xl border border-border/60 p-3">
|
||||||
|
{groups.map((group) => {
|
||||||
|
const groupSelectedCount = group.entries.filter((entry) =>
|
||||||
|
selected.has(entry.key),
|
||||||
|
).length;
|
||||||
|
const allSelected = groupSelectedCount === group.entries.length;
|
||||||
|
return (
|
||||||
|
<div key={group.groupDe} className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`group-${slugify(group.groupDe)}`}
|
||||||
|
checked={allSelected}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={(checked) => toggleGroup(group.entries, checked)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`group-${slugify(group.groupDe)}`}
|
||||||
|
className="text-sm font-semibold text-foreground"
|
||||||
|
>
|
||||||
|
{group.groupDe}
|
||||||
|
</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{groupSelectedCount}/{group.entries.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 pl-6 sm:grid-cols-3">
|
||||||
|
{group.entries.map((entry) => (
|
||||||
|
<div key={entry.key} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`perm-${entry.key}`}
|
||||||
|
checked={selected.has(entry.key)}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={(checked) => toggleOne(entry.key, checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`perm-${entry.key}`} className="text-sm font-normal">
|
||||||
|
{entry.labelDe}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { createUser } from "@/lib/users/api";
|
import { createUser } from "@/lib/users/api";
|
||||||
import { Role } from "@/lib/users/types";
|
import { fetchRoles } from "@/lib/roles/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -23,17 +23,23 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
const ROLES: Role[] = ["ADMIN", "BEEKEEPER", "STAFF", "READONLY", "API_CLIENT"];
|
|
||||||
|
|
||||||
export function CreateUserDialog() {
|
export function CreateUserDialog() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [role, setRole] = useState<Role>("STAFF");
|
const [roleId, setRoleId] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: roles } = useQuery({
|
||||||
|
queryKey: ["roles"],
|
||||||
|
queryFn: fetchRoles,
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
const activeRoles = (roles ?? []).filter((role) => role.status === "ACTIVE");
|
||||||
|
const effectiveRoleId = roleId || activeRoles[0]?.id || "";
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: createUser,
|
mutationFn: createUser,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -42,7 +48,7 @@ export function CreateUserDialog() {
|
|||||||
setEmail("");
|
setEmail("");
|
||||||
setPassword("");
|
setPassword("");
|
||||||
setName("");
|
setName("");
|
||||||
setRole("STAFF");
|
setRoleId("");
|
||||||
setError(null);
|
setError(null);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -53,7 +59,7 @@ export function CreateUserDialog() {
|
|||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
mutation.mutate({ email, password, name, role });
|
mutation.mutate({ email, password, name, roleId: effectiveRoleId });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -96,14 +102,19 @@ export function CreateUserDialog() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="new-user-role">Rolle</Label>
|
<Label htmlFor="new-user-role">Rolle</Label>
|
||||||
<Select value={role} onValueChange={(value) => setRole(value as Role)}>
|
<Select
|
||||||
<SelectTrigger id="new-user-role">
|
value={effectiveRoleId}
|
||||||
<SelectValue />
|
onValueChange={(value) => setRoleId(value ?? effectiveRoleId)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="new-user-role" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{activeRoles.find((role) => role.id === effectiveRoleId)?.name ?? "Rolle wählen"}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{ROLES.map((roleOption) => (
|
{activeRoles.map((role) => (
|
||||||
<SelectItem key={roleOption} value={roleOption}>
|
<SelectItem key={role.id} value={role.id}>
|
||||||
{roleOption}
|
{role.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -115,7 +126,7 @@ export function CreateUserDialog() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
<Button type="submit" disabled={mutation.isPending || !effectiveRoleId}>
|
||||||
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ describe("UsersPage", () => {
|
|||||||
id: "1",
|
id: "1",
|
||||||
email: "[email protected]",
|
email: "[email protected]",
|
||||||
name: "Admin",
|
name: "Admin",
|
||||||
role: "ADMIN",
|
roleId: "role-admin",
|
||||||
|
roleKey: "ADMIN",
|
||||||
|
roleName: "Administrator",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
createdAt: "",
|
createdAt: "",
|
||||||
deactivatedAt: null,
|
deactivatedAt: null,
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default function UsersPage() {
|
|||||||
<TableRow key={user.id}>
|
<TableRow key={user.id}>
|
||||||
<TableCell>{user.name}</TableCell>
|
<TableCell>{user.name}</TableCell>
|
||||||
<TableCell>{user.email}</TableCell>
|
<TableCell>{user.email}</TableCell>
|
||||||
<TableCell>{user.role}</TableCell>
|
<TableCell>{user.roleName}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={user.isActive ? "default" : "secondary"}>
|
<Badge variant={user.isActive ? "default" : "secondary"}>
|
||||||
{user.isActive ? "Aktiv" : "Deaktiviert"}
|
{user.isActive ? "Aktiv" : "Deaktiviert"}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
Repeat,
|
Repeat,
|
||||||
Search,
|
Search,
|
||||||
|
Shield,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
Upload,
|
Upload,
|
||||||
Users,
|
Users,
|
||||||
@@ -33,6 +34,7 @@ interface NavItem {
|
|||||||
label: string;
|
label: string;
|
||||||
href: string;
|
href: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
permission?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavGroup {
|
interface NavGroup {
|
||||||
@@ -82,12 +84,23 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
const ADMIN_GROUP: NavGroup = {
|
const ADMIN_GROUP: NavGroup = {
|
||||||
label: "Verwaltung",
|
label: "Verwaltung",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Benutzer", href: "/settings/users", icon: Users },
|
{ label: "Benutzer", href: "/settings/users", icon: Users, permission: "users:read" },
|
||||||
{ label: "Audit-Log", href: "/settings/audit-log", icon: History },
|
{ label: "Rollen & Rechte", href: "/settings/roles", icon: Shield, permission: "roles:read" },
|
||||||
{ label: "Webhooks", href: "/settings/webhooks", icon: Webhook },
|
{ label: "Audit-Log", href: "/settings/audit-log", icon: History, permission: "audit:read" },
|
||||||
{ label: "Automatisierungen", href: "/settings/automation-rules", icon: Zap },
|
{ label: "Webhooks", href: "/settings/webhooks", icon: Webhook, permission: "webhooks:read" },
|
||||||
{ label: "Wiederkehrende Aufgaben", href: "/settings/task-templates", icon: Repeat },
|
{
|
||||||
{ label: "Import", href: "/settings/import", icon: Upload },
|
label: "Automatisierungen",
|
||||||
|
href: "/settings/automation-rules",
|
||||||
|
icon: Zap,
|
||||||
|
permission: "automation:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Wiederkehrende Aufgaben",
|
||||||
|
href: "/settings/task-templates",
|
||||||
|
icon: Repeat,
|
||||||
|
permission: "task-templates:read",
|
||||||
|
},
|
||||||
|
{ label: "Import", href: "/settings/import", icon: Upload, permission: "import:execute" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,7 +131,13 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
|
|||||||
|
|
||||||
export function Sidebar({ user }: { user: AuthUser }) {
|
export function Sidebar({ user }: { user: AuthUser }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const groups = user.role === "ADMIN" ? [...NAV_GROUPS, ADMIN_GROUP] : NAV_GROUPS;
|
const visibleAdminItems = ADMIN_GROUP.items.filter(
|
||||||
|
(item) => !item.permission || user.permissions.includes(item.permission),
|
||||||
|
);
|
||||||
|
const groups =
|
||||||
|
visibleAdminItems.length > 0
|
||||||
|
? [...NAV_GROUPS, { ...ADMIN_GROUP, items: visibleAdminItems }]
|
||||||
|
: NAV_GROUPS;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="relative z-10 hidden w-64 shrink-0 flex-col gap-1 overflow-y-auto bg-sidebar px-3 py-5 shadow-[var(--shadow-soft)] sm:flex">
|
<aside className="relative z-10 hidden w-64 shrink-0 flex-col gap-1 overflow-y-auto bg-sidebar px-3 py-5 shadow-[var(--shadow-soft)] sm:flex">
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||||
|
import { Check } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer flex size-4 shrink-0 items-center justify-center rounded-[6px] border border-input bg-background shadow-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
|
||||||
|
<Check className="size-3" strokeWidth={3} />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -48,3 +48,23 @@ export function refreshRequest(): Promise<AccessTokenResponse | null> {
|
|||||||
export async function logoutRequest(): Promise<void> {
|
export async function logoutRequest(): Promise<void> {
|
||||||
await fetch(`${API_URL}/auth/logout`, { method: 'POST', credentials: 'include' });
|
await fetch(`${API_URL}/auth/logout`, { method: 'POST', credentials: 'include' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CurrentUserProfile {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
roleKey: string;
|
||||||
|
roleName: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUserRequest(accessToken: string): Promise<CurrentUserProfile> {
|
||||||
|
const response = await fetch(`${API_URL}/auth/me`, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Profil konnte nicht geladen werden.');
|
||||||
|
}
|
||||||
|
return response.json() as Promise<CurrentUserProfile>;
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useState, ReactNode } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useState, ReactNode } from 'react';
|
||||||
import { loginRequest, logoutRequest, refreshRequest } from './api';
|
import { getCurrentUserRequest, loginRequest, logoutRequest, refreshRequest } from './api';
|
||||||
import { decodeAccessToken } from './jwt';
|
|
||||||
import { setAccessToken as storeAccessToken } from './token-store';
|
import { setAccessToken as storeAccessToken } from './token-store';
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
name: string;
|
||||||
|
roleKey: string;
|
||||||
|
roleName: string;
|
||||||
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
@@ -16,17 +18,18 @@ interface AuthContextValue {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
|
hasPermission: (permission: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
|
|
||||||
function applyAccessToken(token: string): AuthUser | null {
|
async function applyAccessToken(token: string): Promise<AuthUser | null> {
|
||||||
storeAccessToken(token);
|
storeAccessToken(token);
|
||||||
const claims = decodeAccessToken(token);
|
try {
|
||||||
if (!claims) {
|
return await getCurrentUserRequest(token);
|
||||||
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { id: claims.sub, email: claims.email, role: claims.role };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
@@ -35,11 +38,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshRequest()
|
refreshRequest()
|
||||||
.then((result) => {
|
.then(async (result) => {
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setUser(applyAccessToken(result.accessToken));
|
setUser(await applyAccessToken(result.accessToken));
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -48,7 +51,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const login = useCallback(async (email: string, password: string) => {
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
const result = await loginRequest(email, password);
|
const result = await loginRequest(email, password);
|
||||||
setUser(applyAccessToken(result.accessToken));
|
setUser(await applyAccessToken(result.accessToken));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
@@ -57,8 +60,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setUser(null);
|
setUser(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const hasPermission = useCallback(
|
||||||
|
(permission: string) => user?.permissions.includes(permission) ?? false,
|
||||||
|
[user],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, isLoading, login, logout }}>
|
<AuthContext.Provider value={{ user, isLoading, login, logout, hasPermission }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { decodeAccessToken } from "./jwt";
|
|
||||||
|
|
||||||
function makeToken(payload: object): string {
|
|
||||||
const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
||||||
const body = btoa(JSON.stringify(payload));
|
|
||||||
return `${header}.${body}.signature`;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("decodeAccessToken", () => {
|
|
||||||
it("decodes a valid JWT payload", () => {
|
|
||||||
const token = makeToken({ sub: "user-1", email: "[email protected]", role: "ADMIN", exp: 123 });
|
|
||||||
expect(decodeAccessToken(token)).toEqual({
|
|
||||||
sub: "user-1",
|
|
||||||
email: "[email protected]",
|
|
||||||
role: "ADMIN",
|
|
||||||
exp: 123,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for a malformed token", () => {
|
|
||||||
expect(decodeAccessToken("not-a-jwt")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export interface AccessTokenClaims {
|
|
||||||
sub: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
exp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeAccessToken(token: string): AccessTokenClaims | null {
|
|
||||||
try {
|
|
||||||
const payload = token.split('.')[1];
|
|
||||||
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
const json = atob(base64);
|
|
||||||
return JSON.parse(json) as AccessTokenClaims;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { apiFetch } from "@/lib/auth/api-client";
|
||||||
|
import {
|
||||||
|
CreateRoleInput,
|
||||||
|
PermissionCatalogEntry,
|
||||||
|
RoleSummary,
|
||||||
|
UpdateRoleInput,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export function fetchRoles(): Promise<RoleSummary[]> {
|
||||||
|
return apiFetch<RoleSummary[]>("/roles");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchPermissionCatalog(): Promise<PermissionCatalogEntry[]> {
|
||||||
|
return apiFetch<PermissionCatalogEntry[]>("/roles/permission-catalog");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRole(input: CreateRoleInput): Promise<RoleSummary> {
|
||||||
|
return apiFetch<RoleSummary>("/roles", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRole(id: string, input: UpdateRoleInput): Promise<RoleSummary> {
|
||||||
|
return apiFetch<RoleSummary>(`/roles/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deactivateRole(id: string): Promise<RoleSummary> {
|
||||||
|
return apiFetch<RoleSummary>(`/roles/${id}/deactivate`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reactivateRole(id: string): Promise<RoleSummary> {
|
||||||
|
return apiFetch<RoleSummary>(`/roles/${id}/reactivate`, { method: "POST" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export interface PermissionCatalogEntry {
|
||||||
|
key: string;
|
||||||
|
resource: string;
|
||||||
|
action: string;
|
||||||
|
labelDe: string;
|
||||||
|
groupDe: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoleStatus = "ACTIVE" | "INACTIVE";
|
||||||
|
|
||||||
|
export interface RoleSummary {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
isSystem: boolean;
|
||||||
|
status: RoleStatus;
|
||||||
|
userCount: number;
|
||||||
|
permissionKeys: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deactivatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRoleInput {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
permissionKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRoleInput {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
permissionKeys?: string[];
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
export type Role = "ADMIN" | "BEEKEEPER" | "STAFF" | "READONLY" | "API_CLIENT";
|
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: Role;
|
roleId: string;
|
||||||
|
roleKey: string;
|
||||||
|
roleName: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
deactivatedAt: string | null;
|
deactivatedAt: string | null;
|
||||||
@@ -14,5 +14,10 @@ export interface CreateUserInput {
|
|||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: Role;
|
roleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserInput {
|
||||||
|
name?: string;
|
||||||
|
roleId?: string;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user