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 { 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 }) {
|
||||
const { user } = useAuth();
|
||||
const hasSettingsAccess = SETTINGS_AREA_PERMISSIONS.some((permission) =>
|
||||
user?.permissions.includes(permission),
|
||||
);
|
||||
|
||||
if (user?.role !== "ADMIN") {
|
||||
if (!hasSettingsAccess) {
|
||||
return (
|
||||
<main className="p-6">
|
||||
<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";
|
||||
|
||||
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 { Role } from "@/lib/users/types";
|
||||
import { fetchRoles } from "@/lib/roles/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -23,17 +23,23 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const ROLES: Role[] = ["ADMIN", "BEEKEEPER", "STAFF", "READONLY", "API_CLIENT"];
|
||||
|
||||
export function CreateUserDialog() {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [role, setRole] = useState<Role>("STAFF");
|
||||
const [roleId, setRoleId] = useState("");
|
||||
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({
|
||||
mutationFn: createUser,
|
||||
onSuccess: () => {
|
||||
@@ -42,7 +48,7 @@ export function CreateUserDialog() {
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
setName("");
|
||||
setRole("STAFF");
|
||||
setRoleId("");
|
||||
setError(null);
|
||||
},
|
||||
onError: () => {
|
||||
@@ -53,7 +59,7 @@ export function CreateUserDialog() {
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate({ email, password, name, role });
|
||||
mutation.mutate({ email, password, name, roleId: effectiveRoleId });
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -96,14 +102,19 @@ export function CreateUserDialog() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-user-role">Rolle</Label>
|
||||
<Select value={role} onValueChange={(value) => setRole(value as Role)}>
|
||||
<SelectTrigger id="new-user-role">
|
||||
<SelectValue />
|
||||
<Select
|
||||
value={effectiveRoleId}
|
||||
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>
|
||||
<SelectContent>
|
||||
{ROLES.map((roleOption) => (
|
||||
<SelectItem key={roleOption} value={roleOption}>
|
||||
{roleOption}
|
||||
{activeRoles.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -115,7 +126,7 @@ export function CreateUserDialog() {
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
<Button type="submit" disabled={mutation.isPending || !effectiveRoleId}>
|
||||
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -29,7 +29,9 @@ describe("UsersPage", () => {
|
||||
id: "1",
|
||||
email: "[email protected]",
|
||||
name: "Admin",
|
||||
role: "ADMIN",
|
||||
roleId: "role-admin",
|
||||
roleKey: "ADMIN",
|
||||
roleName: "Administrator",
|
||||
isActive: true,
|
||||
createdAt: "",
|
||||
deactivatedAt: null,
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function UsersPage() {
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.name}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{user.role}</TableCell>
|
||||
<TableCell>{user.roleName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.isActive ? "default" : "secondary"}>
|
||||
{user.isActive ? "Aktiv" : "Deaktiviert"}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Package,
|
||||
Repeat,
|
||||
Search,
|
||||
Shield,
|
||||
ShoppingBag,
|
||||
Upload,
|
||||
Users,
|
||||
@@ -33,6 +34,7 @@ interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
@@ -82,12 +84,23 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
const ADMIN_GROUP: NavGroup = {
|
||||
label: "Verwaltung",
|
||||
items: [
|
||||
{ label: "Benutzer", href: "/settings/users", icon: Users },
|
||||
{ label: "Audit-Log", href: "/settings/audit-log", icon: History },
|
||||
{ label: "Webhooks", href: "/settings/webhooks", icon: Webhook },
|
||||
{ label: "Automatisierungen", href: "/settings/automation-rules", icon: Zap },
|
||||
{ label: "Wiederkehrende Aufgaben", href: "/settings/task-templates", icon: Repeat },
|
||||
{ label: "Import", href: "/settings/import", icon: Upload },
|
||||
{ label: "Benutzer", href: "/settings/users", icon: Users, permission: "users:read" },
|
||||
{ label: "Rollen & Rechte", href: "/settings/roles", icon: Shield, permission: "roles:read" },
|
||||
{ label: "Audit-Log", href: "/settings/audit-log", icon: History, permission: "audit:read" },
|
||||
{ label: "Webhooks", href: "/settings/webhooks", icon: Webhook, permission: "webhooks:read" },
|
||||
{
|
||||
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 }) {
|
||||
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 (
|
||||
<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">
|
||||
|
||||
Reference in new issue
Block a user