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:
Adrian Astles
2025-08-07 22:57:18 +08:00
parent 4679ce968b
commit 9dce7b2cde
35 changed files with 3199 additions and 88 deletions

View File

@@ -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}

View File

@@ -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
}}
/>
);
}

View 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)}
/>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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`
);
}

View File

@@ -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>
);
}

View 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>
</>
);
}

View File

@@ -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" />
}
]
},

View File

@@ -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)

View 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>
);
}

View 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>
);
}