mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-13 16:36:41 +00:00
Scoped Branch - Rule Templates:
- Add rule templates for reusable access control rules - Support template assignment to resources with automatic rule propagation - Add template management UI - Implement template rule protection on resource rules page
This commit is contained in:
@@ -73,6 +73,7 @@ import {
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ResourceRulesManager } from "@app/components/ruleTemplate/ResourceRulesManager";
|
||||
|
||||
// Schema for rule validation
|
||||
const addRuleSchema = z.object({
|
||||
@@ -122,29 +123,30 @@ export default function ResourceRules(props: {
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRules = async () => {
|
||||
try {
|
||||
const res = await api.get<
|
||||
AxiosResponse<ListResourceRulesResponse>
|
||||
>(`/resource/${params.resourceId}/rules`);
|
||||
if (res.status === 200) {
|
||||
setRules(res.data.data.rules);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('rulesErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
t('rulesErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setPageLoading(false);
|
||||
const fetchRules = async () => {
|
||||
try {
|
||||
const res = await api.get<
|
||||
AxiosResponse<ListResourceRulesResponse>
|
||||
>(`/resource/${params.resourceId}/rules`);
|
||||
if (res.status === 200) {
|
||||
setRules(res.data.data.rules);
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('rulesErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
t('rulesErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setPageLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRules();
|
||||
}, []);
|
||||
|
||||
@@ -208,6 +210,7 @@ export default function ResourceRules(props: {
|
||||
ruleId: new Date().getTime(),
|
||||
new: true,
|
||||
resourceId: resource.resourceId,
|
||||
templateRuleId: null,
|
||||
priority,
|
||||
enabled: true
|
||||
};
|
||||
@@ -434,85 +437,116 @@ export default function ResourceRules(props: {
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: t('rulesAction'),
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.action}
|
||||
onValueChange={(value: "ACCEPT" | "DROP") =>
|
||||
updateRule(row.original.ruleId, { action: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="min-w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACCEPT">
|
||||
{RuleAction.ACCEPT}
|
||||
</SelectItem>
|
||||
<SelectItem value="DROP">{RuleAction.DROP}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
cell: ({ row }) => {
|
||||
const isTemplateRule = row.original.templateRuleId !== null;
|
||||
return (
|
||||
<Select
|
||||
defaultValue={row.original.action}
|
||||
onValueChange={(value: "ACCEPT" | "DROP") =>
|
||||
updateRule(row.original.ruleId, { action: value })
|
||||
}
|
||||
disabled={isTemplateRule}
|
||||
>
|
||||
<SelectTrigger className={`min-w-[150px] ${isTemplateRule ? 'opacity-50 cursor-not-allowed' : ''}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACCEPT">
|
||||
{RuleAction.ACCEPT}
|
||||
</SelectItem>
|
||||
<SelectItem value="DROP">{RuleAction.DROP}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "match",
|
||||
header: t('rulesMatchType'),
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.match}
|
||||
onValueChange={(value: "CIDR" | "IP" | "PATH") =>
|
||||
updateRule(row.original.ruleId, { match: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="min-w-[125px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
|
||||
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
|
||||
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
cell: ({ row }) => {
|
||||
const isTemplateRule = row.original.templateRuleId !== null;
|
||||
return (
|
||||
<Select
|
||||
defaultValue={row.original.match}
|
||||
onValueChange={(value: "CIDR" | "IP" | "PATH") =>
|
||||
updateRule(row.original.ruleId, { match: value })
|
||||
}
|
||||
disabled={isTemplateRule}
|
||||
>
|
||||
<SelectTrigger className={`min-w-[125px] ${isTemplateRule ? 'opacity-50 cursor-not-allowed' : ''}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
|
||||
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
|
||||
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "value",
|
||||
header: t('value'),
|
||||
cell: ({ row }) => (
|
||||
<Input
|
||||
defaultValue={row.original.value}
|
||||
className="min-w-[200px]"
|
||||
onBlur={(e) =>
|
||||
updateRule(row.original.ruleId, {
|
||||
value: e.target.value
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
cell: ({ row }) => {
|
||||
const isTemplateRule = row.original.templateRuleId !== null;
|
||||
return (
|
||||
<Input
|
||||
defaultValue={row.original.value}
|
||||
className={`min-w-[200px] ${isTemplateRule ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onBlur={(e) =>
|
||||
updateRule(row.original.ruleId, {
|
||||
value: e.target.value
|
||||
})
|
||||
}
|
||||
disabled={isTemplateRule}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: t('enabled'),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.original.enabled}
|
||||
onCheckedChange={(val) =>
|
||||
updateRule(row.original.ruleId, { enabled: val })
|
||||
}
|
||||
/>
|
||||
)
|
||||
cell: ({ row }) => {
|
||||
const isTemplateRule = row.original.templateRuleId !== null;
|
||||
return (
|
||||
<Switch
|
||||
defaultChecked={row.original.enabled}
|
||||
onCheckedChange={(val) =>
|
||||
updateRule(row.original.ruleId, { enabled: val })
|
||||
}
|
||||
disabled={isTemplateRule}
|
||||
className={isTemplateRule ? 'opacity-50' : ''}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => removeRule(row.original.ruleId)}
|
||||
>
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
cell: ({ row }) => {
|
||||
const isTemplateRule = row.original.templateRuleId !== null;
|
||||
return (
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{isTemplateRule ? (
|
||||
<div className="text-xs text-muted-foreground bg-muted px-1 py-0.5 rounded">
|
||||
Template
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-blue-600 bg-blue-100 px-1 py-0.5 rounded">
|
||||
Manual
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => removeRule(row.original.ruleId)}
|
||||
disabled={isTemplateRule}
|
||||
className={isTemplateRule ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
>
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -754,6 +788,27 @@ export default function ResourceRules(props: {
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Template Assignment Section */}
|
||||
{rulesEnabled && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t('ruleTemplates')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t('ruleTemplatesDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<ResourceRulesManager
|
||||
resourceId={params.resourceId.toString()}
|
||||
orgId={resource.orgId}
|
||||
onUpdate={fetchRules}
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={saveAllSettings}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
createTemplate?: () => void;
|
||||
}
|
||||
|
||||
export function RuleTemplatesDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
createTemplate
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title={t('ruleTemplates')}
|
||||
searchPlaceholder={t('ruleTemplatesSearch')}
|
||||
searchColumn="name"
|
||||
onAdd={createTemplate}
|
||||
addButtonText={t('ruleTemplateAdd')}
|
||||
defaultSort={{
|
||||
id: "name",
|
||||
desc: false
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
272
src/app/[orgId]/settings/rule-templates/RuleTemplatesTable.tsx
Normal file
272
src/app/[orgId]/settings/rule-templates/RuleTemplatesTable.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { RuleTemplatesDataTable } from "./RuleTemplatesDataTable";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
Plus
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
export type TemplateRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
type RuleTemplatesTableProps = {
|
||||
templates: TemplateRow[];
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
const createTemplateSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(100, "Name must be less than 100 characters"),
|
||||
description: z.string().max(500, "Description must be less than 500 characters").optional()
|
||||
});
|
||||
|
||||
export function RuleTemplatesTable({ templates, orgId }: RuleTemplatesTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<TemplateRow | null>(null);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
|
||||
const form = useForm<z.infer<typeof createTemplateSchema>>({
|
||||
resolver: zodResolver(createTemplateSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: ""
|
||||
}
|
||||
});
|
||||
|
||||
const deleteTemplate = (templateId: string) => {
|
||||
api.delete(`/org/${orgId}/rule-templates/${templateId}`)
|
||||
.catch((e) => {
|
||||
console.error("Failed to delete template:", e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("ruleTemplateErrorDelete"),
|
||||
description: formatAxiosError(e, t("ruleTemplateErrorDelete"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateTemplate = async (values: z.infer<typeof createTemplateSchema>) => {
|
||||
try {
|
||||
const response = await api.post(`/org/${orgId}/rule-templates`, values);
|
||||
|
||||
if (response.status === 201) {
|
||||
setIsCreateDialogOpen(false);
|
||||
form.reset();
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Rule template created successfully"
|
||||
});
|
||||
router.refresh();
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: response.data.message || "Failed to create rule template",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: formatAxiosError(error, "Failed to create rule template"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<TemplateRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
cell: ({ row }) => {
|
||||
const template = row.original;
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{template.description || "No description provided"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const template = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedTemplate(template);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span className="text-red-500">Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${template.orgId}/settings/rule-templates/${template.id}`}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="ml-2"
|
||||
size="sm"
|
||||
>
|
||||
Edit
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedTemplate && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedTemplate(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<p className="mb-2">
|
||||
Are you sure you want to delete the template "{selectedTemplate?.name}"?
|
||||
</p>
|
||||
<p className="mb-2">This action cannot be undone and will remove all rules associated with this template.</p>
|
||||
<p className="mb-2">This will also unassign the template from any resources that are using it.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
To confirm, please type <span className="font-mono font-medium">{selectedTemplate?.name}</span> below.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Delete Template"
|
||||
onConfirm={async () => deleteTemplate(selectedTemplate!.id)}
|
||||
string={selectedTemplate.name}
|
||||
title="Delete Rule Template"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Template Dialog */}
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Rule Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new rule template to define access control rules
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleCreateTemplate)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter template name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter template description (optional)"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Create Template</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<RuleTemplatesDataTable
|
||||
columns={columns}
|
||||
data={templates}
|
||||
createTemplate={() => setIsCreateDialogOpen(true)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Textarea } from "@app/components/ui/textarea";
|
||||
import { Save } from "lucide-react";
|
||||
|
||||
const updateTemplateSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
description: z.string().optional()
|
||||
});
|
||||
|
||||
type UpdateTemplateForm = z.infer<typeof updateTemplateSchema>;
|
||||
|
||||
export default function GeneralPage() {
|
||||
const params = useParams();
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [template, setTemplate] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors }
|
||||
} = useForm<UpdateTemplateForm>({
|
||||
resolver: zodResolver(updateTemplateSchema)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTemplate = async () => {
|
||||
if (!params.orgId || !params.templateId) return;
|
||||
|
||||
try {
|
||||
const response = await api.get(
|
||||
`/org/${params.orgId}/rule-templates/${params.templateId}`
|
||||
);
|
||||
setTemplate(response.data.data);
|
||||
setValue("name", response.data.data.name);
|
||||
setValue("description", response.data.data.description || "");
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("ruleTemplateErrorLoad"),
|
||||
description: formatAxiosError(error, t("ruleTemplateErrorLoadDescription")),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTemplate();
|
||||
}, [params.orgId, params.templateId, setValue, t]);
|
||||
|
||||
const onSubmit = async (data: UpdateTemplateForm) => {
|
||||
if (!params.orgId || !params.templateId) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put(
|
||||
`/org/${params.orgId}/rule-templates/${params.templateId}`,
|
||||
data
|
||||
);
|
||||
toast({
|
||||
title: t("ruleTemplateUpdated"),
|
||||
description: t("ruleTemplateUpdatedDescription")
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("ruleTemplateErrorUpdate"),
|
||||
description: formatAxiosError(error, t("ruleTemplateErrorUpdateDescription")),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!template) {
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">Template not found</div>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle title={t("templateDetails")} />
|
||||
</SettingsSectionHeader>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium mb-2">
|
||||
{t("name")}
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
{...register("name")}
|
||||
className={errors.name ? "border-red-500" : ""}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium mb-2">
|
||||
{t("description")}
|
||||
</label>
|
||||
<Textarea
|
||||
id="description"
|
||||
{...register("description")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={saving}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{saving ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { internal } from "@app/lib/api";
|
||||
import { GetRuleTemplateResponse } from "@server/routers/ruleTemplate";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { cache } from "react";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
interface RuleTemplateLayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ templateId: string; orgId: string }>;
|
||||
}
|
||||
|
||||
export default async function RuleTemplateLayout(props: RuleTemplateLayoutProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
const { children } = props;
|
||||
|
||||
let template = null;
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<GetRuleTemplateResponse>>(
|
||||
`/org/${params.orgId}/rule-templates/${params.templateId}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
template = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings/rule-templates`);
|
||||
}
|
||||
|
||||
if (!template) {
|
||||
redirect(`/${params.orgId}/settings/rule-templates`);
|
||||
}
|
||||
|
||||
let org = null;
|
||||
try {
|
||||
const getOrg = cache(async () =>
|
||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
||||
`/org/${params.orgId}`,
|
||||
await authCookieHeader()
|
||||
)
|
||||
);
|
||||
const res = await getOrg();
|
||||
org = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings/rule-templates`);
|
||||
}
|
||||
|
||||
if (!org) {
|
||||
redirect(`/${params.orgId}/settings/rule-templates`);
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: t('general'),
|
||||
href: `/{orgId}/settings/rule-templates/{templateId}/general`
|
||||
},
|
||||
{
|
||||
title: t('rules'),
|
||||
href: `/{orgId}/settings/rule-templates/{templateId}/rules`
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t('ruleTemplateSetting', {templateName: template?.name})}
|
||||
description={t('ruleTemplateSettingDescription')}
|
||||
/>
|
||||
|
||||
<OrgProvider org={org}>
|
||||
<div className="space-y-6">
|
||||
<HorizontalTabs items={navItems}>
|
||||
{children}
|
||||
</HorizontalTabs>
|
||||
</div>
|
||||
</OrgProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function RuleTemplatePage(props: {
|
||||
params: Promise<{ templateId: string; orgId: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
redirect(
|
||||
`/${params.orgId}/settings/rule-templates/${params.templateId}/general`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { TemplateRulesManager } from "@app/components/ruleTemplate/TemplateRulesManager";
|
||||
|
||||
export default function RulesPage() {
|
||||
const params = useParams();
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle title="Template Rules" />
|
||||
</SettingsSectionHeader>
|
||||
<TemplateRulesManager
|
||||
orgId={params.orgId as string}
|
||||
templateId={params.templateId as string}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
72
src/app/[orgId]/settings/rule-templates/page.tsx
Normal file
72
src/app/[orgId]/settings/rule-templates/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { cache } from "react";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import { redirect } from "next/navigation";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { RuleTemplatesTable } from "./RuleTemplatesTable";
|
||||
|
||||
type RuleTemplatesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RuleTemplatesPage(props: RuleTemplatesPageProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
let templates: any[] = [];
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<any>>(
|
||||
`/org/${params.orgId}/rule-templates`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
templates = res.data.data.templates || [];
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch rule templates:", e);
|
||||
}
|
||||
|
||||
let org = null;
|
||||
try {
|
||||
const getOrg = cache(async () =>
|
||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
||||
`/org/${params.orgId}`,
|
||||
await authCookieHeader()
|
||||
)
|
||||
);
|
||||
const res = await getOrg();
|
||||
org = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings/rule-templates`);
|
||||
}
|
||||
|
||||
if (!org) {
|
||||
redirect(`/${params.orgId}/settings/rule-templates`);
|
||||
}
|
||||
|
||||
const templateRows = templates.map((template) => {
|
||||
return {
|
||||
id: template.templateId,
|
||||
name: template.name,
|
||||
description: template.description || "",
|
||||
orgId: params.orgId
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Rule Templates"
|
||||
description="Create and manage rule templates for consistent access control across your resources"
|
||||
/>
|
||||
|
||||
<OrgProvider org={org}>
|
||||
<RuleTemplatesTable templates={templateRows} orgId={params.orgId} />
|
||||
</OrgProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
TicketCheck,
|
||||
User,
|
||||
Globe, // Added from 'dev' branch
|
||||
MonitorUp // Added from 'dev' branch
|
||||
MonitorUp, // Added from 'dev' branch
|
||||
Shield
|
||||
} from "lucide-react";
|
||||
|
||||
export type SidebarNavSection = { // Added from 'dev' branch
|
||||
@@ -84,6 +85,11 @@ export const orgNavSections = (
|
||||
title: "sidebarShareableLinks",
|
||||
href: "/{orgId}/settings/share-links",
|
||||
icon: <LinkIcon className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: "sidebarRuleTemplates",
|
||||
href: "/{orgId}/settings/rule-templates",
|
||||
icon: <Shield className="h-4 w-4" />
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -36,6 +36,7 @@ export function HorizontalTabs({
|
||||
return href
|
||||
.replace("{orgId}", params.orgId as string)
|
||||
.replace("{resourceId}", params.resourceId as string)
|
||||
.replace("{templateId}", params.templateId as string)
|
||||
.replace("{niceId}", params.niceId as string)
|
||||
.replace("{userId}", params.userId as string)
|
||||
.replace("{clientId}", params.clientId as string)
|
||||
|
||||
204
src/components/ruleTemplate/ResourceRulesManager.tsx
Normal file
204
src/components/ruleTemplate/ResourceRulesManager.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
interface RuleTemplate {
|
||||
templateId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
orgId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ResourceTemplate {
|
||||
templateId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
orgId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function ResourceRulesManager({
|
||||
resourceId,
|
||||
orgId,
|
||||
onUpdate
|
||||
}: {
|
||||
resourceId: string;
|
||||
orgId: string;
|
||||
onUpdate?: () => Promise<void>;
|
||||
}) {
|
||||
const [templates, setTemplates] = useState<RuleTemplate[]>([]);
|
||||
const [resourceTemplates, setResourceTemplates] = useState<ResourceTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string>("");
|
||||
const { toast } = useToast();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [resourceId, orgId]);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [templatesRes, resourceTemplatesRes] = await Promise.all([
|
||||
api.get(`/org/${orgId}/rule-templates`),
|
||||
api.get(`/resource/${resourceId}/templates`)
|
||||
]);
|
||||
|
||||
if (templatesRes.status === 200) {
|
||||
setTemplates(templatesRes.data.data.templates || []);
|
||||
}
|
||||
if (resourceTemplatesRes.status === 200) {
|
||||
setResourceTemplates(resourceTemplatesRes.data.data.templates || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: formatAxiosError(error, "Failed to fetch data"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignTemplate = async (templateId: string) => {
|
||||
if (!templateId) return;
|
||||
|
||||
try {
|
||||
const response = await api.put(`/resource/${resourceId}/templates/${templateId}`);
|
||||
|
||||
if (response.status === 200 || response.status === 201) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Template assigned successfully"
|
||||
});
|
||||
|
||||
setSelectedTemplate("");
|
||||
await fetchData();
|
||||
if (onUpdate) {
|
||||
await onUpdate();
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: response.data.message || "Failed to assign template",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: formatAxiosError(error, "Failed to assign template"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnassignTemplate = async (templateId: string) => {
|
||||
if (!confirm("Are you sure you want to unassign this template?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.delete(`/resource/${resourceId}/templates/${templateId}`);
|
||||
|
||||
if (response.status === 200 || response.status === 201) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Template unassigned successfully"
|
||||
});
|
||||
|
||||
await fetchData();
|
||||
if (onUpdate) {
|
||||
await onUpdate();
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: response.data.message || "Failed to unassign template",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: formatAxiosError(error, "Failed to unassign template"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Template Assignment */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Template Assignment</CardTitle>
|
||||
<CardDescription>
|
||||
Assign rule templates to this resource for consistent access control
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select
|
||||
value={selectedTemplate}
|
||||
onValueChange={(value) => {
|
||||
setSelectedTemplate(value);
|
||||
handleAssignTemplate(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-64">
|
||||
<SelectValue placeholder="Select a template to assign" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((template) => (
|
||||
<SelectItem key={template.templateId} value={template.templateId}>
|
||||
{template.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{resourceTemplates.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Assigned Templates</h4>
|
||||
{resourceTemplates.map((template) => (
|
||||
<div key={template.templateId} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium">{template.name}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{template.description}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleUnassignTemplate(template.templateId)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Unassign
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
449
src/components/ruleTemplate/TemplateRulesManager.tsx
Normal file
449
src/components/ruleTemplate/TemplateRulesManager.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import {
|
||||
ColumnDef,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
flexRender
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@app/components/ui/table";
|
||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "@server/lib/validators";
|
||||
import { ArrowUpDown, Trash2 } from "lucide-react";
|
||||
|
||||
const addRuleSchema = z.object({
|
||||
action: z.enum(["ACCEPT", "DROP"]),
|
||||
match: z.enum(["CIDR", "IP", "PATH"]),
|
||||
value: z.string().min(1),
|
||||
priority: z.coerce.number().int().optional()
|
||||
});
|
||||
|
||||
type TemplateRule = {
|
||||
ruleId: number;
|
||||
templateId: string;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
action: string;
|
||||
match: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type TemplateRulesManagerProps = {
|
||||
templateId: string;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export function TemplateRulesManager({ templateId, orgId }: TemplateRulesManagerProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [rules, setRules] = useState<TemplateRule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [addingRule, setAddingRule] = useState(false);
|
||||
|
||||
const form = useForm<z.infer<typeof addRuleSchema>>({
|
||||
resolver: zodResolver(addRuleSchema),
|
||||
defaultValues: {
|
||||
action: "ACCEPT",
|
||||
match: "IP",
|
||||
value: "",
|
||||
priority: undefined
|
||||
}
|
||||
});
|
||||
|
||||
const fetchRules = async () => {
|
||||
try {
|
||||
const response = await api.get(`/org/${orgId}/rule-templates/${templateId}/rules`);
|
||||
setRules(response.data.data.rules);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch template rules:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: formatAxiosError(error, "Failed to fetch template rules")
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRules();
|
||||
}, [templateId, orgId]);
|
||||
|
||||
const addRule = async (data: z.infer<typeof addRuleSchema>) => {
|
||||
try {
|
||||
setAddingRule(true);
|
||||
|
||||
// Validate the value based on match type
|
||||
if (data.match === "CIDR" && !isValidCIDR(data.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid CIDR format",
|
||||
description: "Please enter a valid CIDR notation (e.g., 192.168.1.0/24)"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (data.match === "IP" && !isValidIP(data.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid IP address",
|
||||
description: "Please enter a valid IP address"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid URL pattern",
|
||||
description: "Please enter a valid URL pattern"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await api.post(`/org/${orgId}/rule-templates/${templateId}/rules`, data);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Rule added successfully"
|
||||
});
|
||||
form.reset();
|
||||
fetchRules();
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: formatAxiosError(error, "Failed to add rule")
|
||||
});
|
||||
} finally {
|
||||
setAddingRule(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeRule = async (ruleId: number) => {
|
||||
try {
|
||||
await api.delete(`/org/${orgId}/rule-templates/${templateId}/rules/${ruleId}`);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Rule removed successfully"
|
||||
});
|
||||
fetchRules();
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: formatAxiosError(error, "Failed to remove rule")
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateRule = async (ruleId: number, data: Partial<TemplateRule>) => {
|
||||
try {
|
||||
await api.put(`/org/${orgId}/rule-templates/${templateId}/rules/${ruleId}`, data);
|
||||
fetchRules();
|
||||
} catch (error) {
|
||||
console.error("Failed to update rule:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<TemplateRule>[] = [
|
||||
{
|
||||
accessorKey: "priority",
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Priority
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Input
|
||||
type="number"
|
||||
defaultValue={row.original.priority}
|
||||
className="w-20"
|
||||
onBlur={(e) =>
|
||||
updateRule(row.original.ruleId, {
|
||||
priority: parseInt(e.target.value, 10)
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: "Action",
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.action}
|
||||
onValueChange={(value) =>
|
||||
updateRule(row.original.ruleId, { action: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACCEPT">Accept</SelectItem>
|
||||
<SelectItem value="DROP">Drop</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "match",
|
||||
header: "Match",
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.match}
|
||||
onValueChange={(value) =>
|
||||
updateRule(row.original.ruleId, { match: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="IP">IP</SelectItem>
|
||||
<SelectItem value="CIDR">CIDR</SelectItem>
|
||||
<SelectItem value="PATH">Path</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "value",
|
||||
header: "Value",
|
||||
cell: ({ row }) => (
|
||||
<Input
|
||||
defaultValue={row.original.value}
|
||||
className="min-w-[200px]"
|
||||
onBlur={(e) =>
|
||||
updateRule(row.original.ruleId, { value: e.target.value })
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: "Enabled",
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.original.enabled}
|
||||
onCheckedChange={(val) =>
|
||||
updateRule(row.original.ruleId, { enabled: val })
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeRule(row.original.ruleId)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: rules,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
state: {
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: 1000
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading rules...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(addRule)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-end">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="action"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Action</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACCEPT">Accept</SelectItem>
|
||||
<SelectItem value="DROP">Drop</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="match"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Match</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="IP">IP</SelectItem>
|
||||
<SelectItem value="CIDR">CIDR</SelectItem>
|
||||
<SelectItem value="PATH">Path</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Value</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter value" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priority"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Priority (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="secondary" disabled={addingRule}>
|
||||
{addingRule ? "Adding Rule..." : "Add Rule"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No rules found. Add your first rule above.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user