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:
Nick SzaunigandClaude Sonnet 5 committed 2026-08-26 09:27:23 +02:00
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"}
+26 -7
View File
@@ -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">
@@ -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 }
+20
View File
@@ -48,3 +48,23 @@ export function refreshRequest(): Promise<AccessTokenResponse | null> {
export async function logoutRequest(): Promise<void> {
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>;
}
+19 -11
View File
@@ -1,14 +1,16 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState, ReactNode } from 'react';
import { loginRequest, logoutRequest, refreshRequest } from './api';
import { decodeAccessToken } from './jwt';
import { getCurrentUserRequest, loginRequest, logoutRequest, refreshRequest } from './api';
import { setAccessToken as storeAccessToken } from './token-store';
export interface AuthUser {
id: string;
email: string;
role: string;
name: string;
roleKey: string;
roleName: string;
permissions: string[];
}
interface AuthContextValue {
@@ -16,17 +18,18 @@ interface AuthContextValue {
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
hasPermission: (permission: string) => boolean;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
function applyAccessToken(token: string): AuthUser | null {
async function applyAccessToken(token: string): Promise<AuthUser | null> {
storeAccessToken(token);
const claims = decodeAccessToken(token);
if (!claims) {
try {
return await getCurrentUserRequest(token);
} catch {
return null;
}
return { id: claims.sub, email: claims.email, role: claims.role };
}
export function AuthProvider({ children }: { children: ReactNode }) {
@@ -35,11 +38,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
refreshRequest()
.then((result) => {
.then(async (result) => {
if (!result) {
return;
}
setUser(applyAccessToken(result.accessToken));
setUser(await applyAccessToken(result.accessToken));
})
.finally(() => {
setIsLoading(false);
@@ -48,7 +51,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const login = useCallback(async (email: string, password: string) => {
const result = await loginRequest(email, password);
setUser(applyAccessToken(result.accessToken));
setUser(await applyAccessToken(result.accessToken));
}, []);
const logout = useCallback(async () => {
@@ -57,8 +60,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(null);
}, []);
const hasPermission = useCallback(
(permission: string) => user?.permissions.includes(permission) ?? false,
[user],
);
return (
<AuthContext.Provider value={{ user, isLoading, login, logout }}>
<AuthContext.Provider value={{ user, isLoading, login, logout, hasPermission }}>
{children}
</AuthContext.Provider>
);
-24
View File
@@ -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();
});
});
-17
View File
@@ -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;
}
}
+37
View File
@@ -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" });
}
+37
View File
@@ -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[];
}
+9 -4
View File
@@ -1,10 +1,10 @@
export type Role = "ADMIN" | "BEEKEEPER" | "STAFF" | "READONLY" | "API_CLIENT";
export interface User {
id: string;
email: string;
name: string;
role: Role;
roleId: string;
roleKey: string;
roleName: string;
isActive: boolean;
createdAt: string;
deactivatedAt: string | null;
@@ -14,5 +14,10 @@ export interface CreateUserInput {
email: string;
password: string;
name: string;
role: Role;
roleId: string;
}
export interface UpdateUserInput {
name?: string;
roleId?: string;
}