feat: Verwaltungsmenü zu 3 Sammelseiten umgebaut - Benutzer & Rollen, Automatisierung, Backup
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:
1 parent
2f223b6941
commit
d998f611e2
24 files changed
+371
-148
No files matched your search
+3
-12
@@ -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>
|
||||
);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+9
-16
@@ -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={
|
||||
<Button render={<Link href="/settings/label-templates/new" />}>
|
||||
<Plus className="size-4" />
|
||||
Neue Etikettenvorlage
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+3
-12
@@ -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>
|
||||
);
|
||||
}
|
||||
+12
-18
@@ -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 />}
|
||||
/>
|
||||
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={activeTemplates.length}
|
||||
historyCount={historyTemplates.length}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+6
-12
@@ -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>
|
||||
);
|
||||
}
|
||||
+111
-11
@@ -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 }) {
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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>
|
||||
);
|
||||
}
|
||||
File renamed without changes.
+12
-18
@@ -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 />}
|
||||
/>
|
||||
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={activeRoles.length}
|
||||
historyCount={historyRoles.length}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+4
-4
@@ -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);
|
||||
});
|
||||
+6
-11
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user