feat: Verwaltungsmenü zu 3 Sammelseiten umgebaut - Benutzer & Rollen, Automatisierung, Backup
Backend Image / build-and-push (push) Successful in 2m17s
Frontend Image / build-and-push (push) Successful in 2m18s

Menü unter "Verwaltung" wie gewünscht neu strukturiert:

- "Benutzer & Rollen" (/settings/users-roles): Benutzerverwaltung und
  Rollen & Rechte als zwei Reiter auf einer Seite (vorher zwei
  getrennte Menüpunkte).
- "Automatisierung" (/settings/automation): Webhooks, Wiederkehrende
  Aufgaben, Etikettenvorlagen und Social Media Links als Reiter
  gebündelt - dazu (eigene Ergänzung, da sonst zwei fast gleich
  benannte Menüpunkte "Automatisierung"/"Automatisierungen"
  nebeneinander gestanden hätten) auch die bestehenden
  Automatisierungsregeln als fünfter Reiter "Regeln".
- "Backup" (/settings/backup): neuer voller Daten-Export/Import
  (Commit zuvor) plus der bisherige CSV-Import, beides auf einer Seite.

Aktiver Reiter wird als ?tab=... in der URL gehalten statt nur im
Client-State, damit z.B. "Speichern" im Etiketten-Designer wieder auf
den richtigen Reiter zurückführt.

Technisch: bestehende Seiteninhalte in Reiter-Komponenten extrahiert
(PageHeader entfernt, Rest unverändert übernommen), Dialog-Unterkomponenten
in die neuen Ordner verschoben (git mv, Historie bleibt erhalten). Die
Etiketten-Editor-Routen (/settings/label-templates/new, /[id]) bleiben
unverändert bestehen, nur der Rücksprung nach dem Speichern zeigt jetzt
auf den neuen Reiter. settings/layout.tsx's Berechtigungsliste um
label-templates:read, site-settings:read, backup:export/import ergänzt -
schließt nebenbei eine bestehende Lücke (eine Rolle mit ausschließlich
diesen Rechten wurde vorher faelschlich von der ganzen
Verwaltungssektion ausgesperrt.

Neuer Reiter-Baustein src/components/tab-switcher.tsx (generisch, nach
Vorbild von ActiveHistoryTabs). sidebar.tsx's NavItem.permission
unterstützt jetzt auch ein Array (irgendeine der Berechtigungen reicht),
fuer die neuen gebündelten Menüpunkte.

Lokal verifiziert: typecheck/lint/test/build fehlerfrei, Build zeigt
alle neuen Routen korrekt, Live-Test im Browser (ohne Login) zeigt
sauberen Redirect zu /login ohne Konsolenfehler.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-03 10:37:22 +02:00
1 parent 2f223b6941
commit d998f611e2
24 files changed
+355 -132

No files matched your search

@@ -1,7 +1,6 @@
"use client";
import { useState } from "react";
import { Zap } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchAutomationRules, updateAutomationRule } from "@/lib/automation/api";
@@ -10,7 +9,6 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const TRIGGER_LABELS: Record<AutomationTriggerType, string> = {
@@ -115,21 +113,14 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
);
}
export default function AutomationRulesPage() {
export function AutomationRulesTab() {
const { data: rules, isLoading, isError } = useQuery({
queryKey: ["automation-rules"],
queryFn: fetchAutomationRules,
});
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Zap}
title="Automatisierungen"
description="Fest hinterlegte Regeln aktivieren, deaktivieren und anpassen"
gradient="var(--gradient-sky)"
/>
<div className="flex flex-col gap-4">
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
@@ -142,6 +133,6 @@ export default function AutomationRulesPage() {
<RuleCard key={rule.id} rule={rule} />
))}
</div>
</main>
</div>
);
}
@@ -7,11 +7,10 @@ import { toast } from "sonner";
import { deactivateLabelTemplate, fetchLabelTemplates } from "@/lib/label-templates/api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
export default function LabelTemplatesPage() {
export function LabelTemplatesTab() {
const queryClient = useQueryClient();
const { data: templates, isLoading, isError } = useQuery({
queryKey: ["label-templates"],
@@ -30,19 +29,13 @@ export default function LabelTemplatesPage() {
const activeTemplates = (templates ?? []).filter((template) => template.active);
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Tag}
title="Etikettenvorlagen"
description="Vorlagen mit Platzhaltern für den Etikettendruck verwalten"
gradient="var(--gradient-sky)"
actions={
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Button render={<Link href="/settings/label-templates/new" />}>
<Plus className="size-4" />
Neue Etikettenvorlage
</Button>
}
/>
</div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
@@ -96,6 +89,6 @@ export default function LabelTemplatesPage() {
))}
</div>
) : null}
</main>
</div>
);
}
@@ -0,0 +1,63 @@
"use client";
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Zap } from "lucide-react";
import { PageHeader } from "@/components/page-header";
import { TabSwitcher } from "@/components/tab-switcher";
import { WebhooksTab } from "./webhooks-tab";
import { TaskTemplatesTab } from "./task-templates-tab";
import { LabelTemplatesTab } from "./label-templates-tab";
import { SocialMediaTab } from "./social-media-tab";
import { AutomationRulesTab } from "./automation-rules-tab";
type Tab = "webhooks" | "task-templates" | "label-templates" | "social-media" | "rules";
const TABS: { value: Tab; label: string }[] = [
{ value: "webhooks", label: "Webhooks" },
{ value: "task-templates", label: "Wiederkehrende Aufgaben" },
{ value: "label-templates", label: "Etikettenvorlagen" },
{ value: "social-media", label: "Social Media" },
{ value: "rules", label: "Regeln" },
];
function isTab(value: string | null): value is Tab {
return TABS.some((tab) => tab.value === value);
}
function AutomationPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const tab: Tab = isTab(searchParams.get("tab")) ? (searchParams.get("tab") as Tab) : "webhooks";
function setTab(next: Tab) {
router.replace(`/settings/automation?tab=${next}`);
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Zap}
title="Automatisierung"
description="Webhooks, wiederkehrende Aufgaben, Etikettenvorlagen, Social Media und Regeln"
gradient="var(--gradient-sky)"
/>
<TabSwitcher tabs={TABS} value={tab} onChange={setTab} />
{tab === "webhooks" ? <WebhooksTab /> : null}
{tab === "task-templates" ? <TaskTemplatesTab /> : null}
{tab === "label-templates" ? <LabelTemplatesTab /> : null}
{tab === "social-media" ? <SocialMediaTab /> : null}
{tab === "rules" ? <AutomationRulesTab /> : null}
</main>
);
}
export default function AutomationPage() {
return (
<Suspense fallback={null}>
<AutomationPageContent />
</Suspense>
);
}
@@ -1,7 +1,6 @@
"use client";
import { FormEvent, useState } from "react";
import { Globe } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchSiteSettings, updateSiteSettings } from "@/lib/site-settings/api";
@@ -9,7 +8,6 @@ import { SiteSettings } from "@/lib/site-settings/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
// Mounted only once `settings` has arrived, so its useState initial values
@@ -85,21 +83,14 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
);
}
export default function WebsiteSettingsPage() {
export function SocialMediaTab() {
const { data: settings, isLoading, isError } = useQuery({
queryKey: ["site-settings"],
queryFn: fetchSiteSettings,
});
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Globe}
title="Website"
description="Social-Media-Links auf der öffentlichen Herkunftsseite"
gradient="var(--gradient-sky)"
/>
<div className="flex flex-col gap-4">
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
@@ -108,6 +99,6 @@ export default function WebsiteSettingsPage() {
) : null}
{settings ? <WebsiteSettingsForm settings={settings} /> : null}
</main>
</div>
);
}
@@ -8,7 +8,6 @@ import { deactivateTaskTemplate, fetchTaskTemplates } from "@/lib/task-templates
import { TaskTemplate, TaskTemplateRecurrenceType } from "@/lib/task-templates/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
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";
@@ -35,7 +34,7 @@ function formatDate(value: string): string {
return new Date(value).toLocaleDateString("de-DE");
}
export default function TaskTemplatesPage() {
export function TaskTemplatesTab() {
const queryClient = useQueryClient();
const [tab, setTab] = useState<EntityLifecycleTab>("active");
const { data: templates, isLoading, isError } = useQuery({
@@ -57,21 +56,16 @@ export default function TaskTemplatesPage() {
const visibleTemplates = tab === "active" ? activeTemplates : historyTemplates;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Repeat}
title="Wiederkehrende Aufgaben"
description="Vorlagen für regelmäßig automatisch angelegte Aufgaben verwalten"
gradient="var(--gradient-sky)"
actions={<CreateTaskTemplateDialog />}
/>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<ActiveHistoryTabs
value={tab}
onChange={setTab}
activeCount={activeTemplates.length}
historyCount={historyTemplates.length}
/>
<CreateTaskTemplateDialog />
</div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
@@ -127,6 +121,6 @@ export default function TaskTemplatesPage() {
))}
</div>
) : null}
</main>
</div>
);
}
@@ -1,12 +1,10 @@
"use client";
import { Webhook } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { deleteWebhook, fetchWebhooks } from "@/lib/webhooks/api";
import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { PageHeader } from "@/components/page-header";
import {
Table,
TableBody,
@@ -17,7 +15,7 @@ import {
} from "@/components/ui/table";
import { CreateWebhookDialog } from "./create-webhook-dialog";
export default function WebhooksPage() {
export function WebhooksTab() {
const queryClient = useQueryClient();
const { data: webhooks, isLoading, isError } = useQuery({
queryKey: ["webhooks"],
@@ -34,14 +32,10 @@ export default function WebhooksPage() {
});
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Webhook}
title="Webhooks"
description="Externe Systeme über Ereignisse in ApiCore benachrichtigen"
gradient="var(--gradient-sky)"
actions={<CreateWebhookDialog />}
/>
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<CreateWebhookDialog />
</div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
@@ -101,6 +95,6 @@ export default function WebhooksPage() {
</Table>
</div>
) : null}
</main>
</div>
);
}
@@ -1,15 +1,17 @@
"use client";
import { ChangeEvent, useState } from "react";
import { Upload } from "lucide-react";
import { Database } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { exportBackup, importBackup } from "@/lib/backup/api";
import { importCsv } from "@/lib/import/api";
import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/page-header";
import { ConfirmDialog } from "@/components/confirm-dialog";
import {
Select,
SelectContent,
@@ -27,6 +29,90 @@ import {
TableRow,
} from "@/components/ui/table";
function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
function BackupSection() {
const [file, setFile] = useState<File | null>(null);
const exportMutation = useMutation({
mutationFn: exportBackup,
onSuccess: ({ blob, filename }) => {
downloadBlob(blob, filename);
toast.success("Backup heruntergeladen");
},
onError: () => toast.error("Backup konnte nicht exportiert werden"),
});
const importMutation = useMutation({
mutationFn: () => {
if (!file) {
throw new Error("Keine Datei ausgewählt");
}
return importBackup(file);
},
onSuccess: (summary) => {
const total = Object.values(summary.imported).reduce((sum, count) => sum + count, 0);
toast.success(`Backup eingespielt: ${total} Datensätze verarbeitet`);
setFile(null);
},
onError: () => toast.error("Backup konnte nicht eingespielt werden"),
});
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
setFile(event.target.files?.[0] ?? null);
}
return (
<Card className="max-w-lg">
<CardHeader>
<CardTitle>Backup</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
<p className="text-muted-foreground">
Sichert Bienenstände bis Etikettenvorlagen (Betriebsdaten) in einer JSON-Datei -
für Backups und zur Übertragung auf ein anderes Deployment. Benutzer, Rollen,
Audit-Log und hochgeladene Dokumente sind nicht enthalten.
</p>
<Button
type="button"
variant="outline"
disabled={exportMutation.isPending}
onClick={() => exportMutation.mutate()}
>
{exportMutation.isPending ? "Wird exportiert…" : "Backup exportieren"}
</Button>
<div className="flex flex-col gap-2 border-t border-border/60 pt-4">
<Label htmlFor="backup-file">Backup-Datei einspielen</Label>
<input
id="backup-file"
type="file"
accept=".json,application/json"
onChange={handleFileChange}
className="text-sm file:mr-3 file:rounded-lg file:border-0 file:bg-muted file:px-3 file:py-2 file:text-sm file:font-medium"
/>
<ConfirmDialog
triggerLabel="Backup importieren"
triggerDisabled={!file || importMutation.isPending}
title="Backup einspielen?"
description="Datensätze aus der Datei werden angelegt oder mit bestehenden gleicher ID überschrieben. Bereits vorhandene Daten, die in der Datei nicht enthalten sind, bleiben unverändert. Das kann bei einer großen Datei einen Moment dauern."
confirmLabel="Einspielen"
isPending={importMutation.isPending}
onConfirm={() => importMutation.mutate()}
/>
</div>
</CardContent>
</Card>
);
}
const ENTITY_LABELS: Record<ImportEntity, string> = {
apiaries: "Bienenstände",
colonies: "Völker",
@@ -53,7 +139,7 @@ const ROW_STATUS_LABELS: Record<ImportRowStatus, string> = {
error: "Fehler",
};
export default function ImportPage() {
function CsvImportSection() {
const [entity, setEntity] = useState<ImportEntity>("apiaries");
const [file, setFile] = useState<File | null>(null);
const [report, setReport] = useState<ImportReport | null>(null);
@@ -87,19 +173,15 @@ export default function ImportPage() {
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Upload}
title="CSV-Import"
description="Stammdaten aus einer CSV-Datei anlegen"
gradient="var(--gradient-sky)"
/>
<div className="flex flex-col gap-4">
<Card className="max-w-lg">
<CardHeader>
<CardTitle>Datei hochladen</CardTitle>
<CardTitle>CSV-Import</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
<p className="text-muted-foreground">
Einzelne Stammdaten (kein Backup) aus einer CSV-Tabelle neu anlegen.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="import-entity">Datentyp</Label>
<Select
@@ -173,6 +255,24 @@ export default function ImportPage() {
</CardContent>
</Card>
) : null}
</div>
);
}
export default function BackupPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Database}
title="Backup"
description="Daten sichern, wiederherstellen und übertragen"
gradient="var(--gradient-sky)"
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<BackupSection />
<CsvImportSection />
</div>
</main>
);
}
@@ -317,7 +317,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
gapYMm,
isDefault,
});
router.push("/settings/label-templates");
router.push("/settings/automation?tab=label-templates");
} catch {
toast.error("Vorlage konnte nicht gespeichert werden.");
} finally {
@@ -11,7 +11,11 @@ const SETTINGS_AREA_PERMISSIONS = [
"webhooks:read",
"automation:read",
"task-templates:read",
"label-templates:read",
"site-settings:read",
"import:execute",
"backup:export",
"backup:import",
];
export default function SettingsLayout({ children }: { children: ReactNode }) {
@@ -0,0 +1,53 @@
"use client";
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Users } from "lucide-react";
import { PageHeader } from "@/components/page-header";
import { TabSwitcher } from "@/components/tab-switcher";
import { UsersTab } from "./users-tab";
import { RolesTab } from "./roles-tab";
type Tab = "users" | "roles";
const TABS: { value: Tab; label: string }[] = [
{ value: "users", label: "Benutzer" },
{ value: "roles", label: "Rollen & Rechte" },
];
function isTab(value: string | null): value is Tab {
return value === "users" || value === "roles";
}
function UsersRolesPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const tab: Tab = isTab(searchParams.get("tab")) ? searchParams.get("tab") as Tab : "users";
function setTab(next: Tab) {
router.replace(`/settings/users-roles?tab=${next}`);
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Users}
title="Benutzer & Rollen"
description="Zugänge, Rollen und Berechtigungen verwalten"
gradient="var(--gradient-sage)"
/>
<TabSwitcher tabs={TABS} value={tab} onChange={setTab} />
{tab === "users" ? <UsersTab /> : <RolesTab />}
</main>
);
}
export default function UsersRolesPage() {
return (
<Suspense fallback={null}>
<UsersRolesPageContent />
</Suspense>
);
}
@@ -14,14 +14,13 @@ 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() {
export function RolesTab() {
const queryClient = useQueryClient();
const [tab, setTab] = useState<EntityLifecycleTab>("active");
const [editingRole, setEditingRole] = useState<RoleSummary | null>(null);
@@ -59,21 +58,16 @@ export default function RolesPage() {
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 />}
/>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<ActiveHistoryTabs
value={tab}
onChange={setTab}
activeCount={activeRoles.length}
historyCount={historyRoles.length}
/>
<CreateRoleDialog />
</div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
@@ -169,6 +163,6 @@ export default function RolesPage() {
) : null}
<EditRoleDialog role={editingRole} onClose={() => setEditingRole(null)} />
</main>
</div>
);
}
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactElement } from "react";
import UsersPage from "./page";
import { UsersTab } from "./users-tab";
import { deactivateUser, fetchUsers } from "@/lib/users/api";
vi.mock("@/lib/users/api", () => ({
@@ -18,7 +18,7 @@ function renderWithClient(ui: ReactElement) {
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
describe("UsersPage", () => {
describe("UsersTab", () => {
beforeEach(() => {
vi.mocked(fetchUsers).mockReset();
vi.mocked(deactivateUser).mockReset();
@@ -40,7 +40,7 @@ describe("UsersPage", () => {
},
]);
renderWithClient(<UsersPage />);
renderWithClient(<UsersTab />);
expect(await screen.findByText("[email protected]")).toBeInTheDocument();
expect(screen.getByText("Aktiv")).toBeInTheDocument();
@@ -49,7 +49,7 @@ describe("UsersPage", () => {
it("shows an error message when loading users fails", async () => {
vi.mocked(fetchUsers).mockRejectedValueOnce(new Error("boom"));
renderWithClient(<UsersPage />);
renderWithClient(<UsersTab />);
expect(await screen.findByRole("alert")).toHaveTextContent(/nicht geladen/i);
});
@@ -7,7 +7,6 @@ import { activateUser, deactivateUser, fetchUsers, resetUserMfa } from "@/lib/us
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import {
Table,
@@ -19,7 +18,7 @@ import {
} from "@/components/ui/table";
import { CreateUserDialog } from "./create-user-dialog";
export default function UsersPage() {
export function UsersTab() {
const queryClient = useQueryClient();
const { data: users, isLoading, isError } = useQuery({
queryKey: ["users"],
@@ -56,14 +55,10 @@ export default function UsersPage() {
});
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Users}
title="Benutzerverwaltung"
description="Zugänge und Rollen verwalten"
gradient="var(--gradient-sage)"
actions={<CreateUserDialog />}
/>
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<CreateUserDialog />
</div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
@@ -147,6 +142,6 @@ export default function UsersPage() {
</TableBody>
</Table>
) : null}
</main>
</div>
);
}
+35 -33
View File
@@ -6,10 +6,10 @@ import {
BarChart3,
CalendarDays,
Crown,
Database,
Droplet,
FileClock,
FlaskConical,
Globe,
Hexagon,
History,
LayoutDashboard,
@@ -18,16 +18,11 @@ import {
type LucideIcon,
MapPin,
Package,
Repeat,
Scissors,
Search,
Shield,
ShoppingBag,
Tag,
Upload,
Users,
Warehouse,
Webhook,
Wrench,
Zap,
} from "lucide-react";
@@ -38,7 +33,10 @@ interface NavItem {
label: string;
href: string;
icon: LucideIcon;
permission?: string;
// Any one of these grants visibility - used for hub pages that fold
// together several previously-separate settings pages, each gated by its
// own permission.
permission?: string | string[];
}
interface NavGroup {
@@ -90,35 +88,31 @@ const NAV_GROUPS: NavGroup[] = [
const ADMIN_GROUP: NavGroup = {
label: "Verwaltung",
items: [
{ 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",
label: "Benutzer & Rollen",
href: "/settings/users-roles",
icon: Users,
permission: ["users:read", "roles:read"],
},
{
label: "Automatisierung",
href: "/settings/automation",
icon: Zap,
permission: "automation:read",
permission: [
"webhooks:read",
"automation:read",
"task-templates:read",
"label-templates:read",
"site-settings:read",
],
},
{
label: "Wiederkehrende Aufgaben",
href: "/settings/task-templates",
icon: Repeat,
permission: "task-templates:read",
},
{
label: "Etikettenvorlagen",
href: "/settings/label-templates",
icon: Tag,
permission: "label-templates:read",
},
{ label: "Import", href: "/settings/import", icon: Upload, permission: "import:execute" },
{
label: "Website",
href: "/settings/website",
icon: Globe,
permission: "site-settings:read",
label: "Backup",
href: "/settings/backup",
icon: Database,
permission: ["backup:export", "backup:import", "import:execute"],
},
{ label: "Audit-Log", href: "/settings/audit-log", icon: History, permission: "audit:read" },
],
};
@@ -147,10 +141,18 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
);
}
function hasNavPermission(user: AuthUser, permission: NavItem["permission"]): boolean {
if (!permission) {
return true;
}
const required = Array.isArray(permission) ? permission : [permission];
return required.some((key) => user.permissions.includes(key));
}
export function Sidebar({ user }: { user: AuthUser }) {
const pathname = usePathname();
const visibleAdminItems = ADMIN_GROUP.items.filter(
(item) => !item.permission || user.permissions.includes(item.permission),
const visibleAdminItems = ADMIN_GROUP.items.filter((item) =>
hasNavPermission(user, item.permission),
);
const groups =
visibleAdminItems.length > 0
@@ -0,0 +1,34 @@
import { cn } from "@/lib/utils";
interface TabSwitcherProps<T extends string> {
tabs: { value: T; label: string }[];
value: T;
onChange: (value: T) => void;
}
export function TabSwitcher<T extends string>({ tabs, value, onChange }: TabSwitcherProps<T>) {
return (
<div
role="tablist"
className="inline-flex w-fit flex-wrap gap-1 rounded-xl border border-border/60 bg-muted/40 p-1"
>
{tabs.map((tab) => (
<button
key={tab.value}
type="button"
role="tab"
aria-selected={value === tab.value}
onClick={() => onChange(tab.value)}
className={cn(
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors",
value === tab.value
? "bg-card text-foreground shadow-[var(--shadow-soft)]"
: "text-muted-foreground hover:text-foreground",
)}
>
{tab.label}
</button>
))}
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { apiFetchBlob, apiFetchFormData, BlobDownload } from "@/lib/auth/api-client";
import { BackupImportSummary } from "./types";
export function exportBackup(): Promise<BlobDownload> {
return apiFetchBlob("/backup/export", "apicore-backup.json");
}
export function importBackup(file: File): Promise<BackupImportSummary> {
const formData = new FormData();
formData.append("file", file);
return apiFetchFormData<BackupImportSummary>("/backup/import", formData);
}
+3
View File
@@ -0,0 +1,3 @@
export interface BackupImportSummary {
imported: Record<string, number>;
}