Resource Rules page:

Split into 3 clear sections: Enabled Rules (with explanation), Rule Templates, and Resource Rules Configuration
Hide Rules Configuration when rules are disabled

Rule Template pages:
Rules: adopt Settings section layout; right-aligned “Add Rule” button that opens a Create Rule dialog; remove inline add form; consistent table styling
This commit is contained in:
Adrian Astles
2025-08-08 19:30:26 +08:00
parent 16a88281bb
commit 75cec731e8
5 changed files with 498 additions and 466 deletions

View File

@@ -57,8 +57,7 @@ import {
} from "@app/components/Settings"; } from "@app/components/Settings";
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules"; import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { ArrowUpDown, Check, X, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { ArrowUpDown, Check, InfoIcon, X, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { import {
InfoSection, InfoSection,
InfoSections, InfoSections,
@@ -74,6 +73,15 @@ import { Switch } from "@app/components/ui/switch";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { ResourceRulesManager } from "@app/components/ruleTemplate/ResourceRulesManager"; import { ResourceRulesManager } from "@app/components/ruleTemplate/ResourceRulesManager";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@app/components/ui/dialog";
// Schema for rule validation // Schema for rule validation
const addRuleSchema = z.object({ const addRuleSchema = z.object({
@@ -103,6 +111,7 @@ export default function ResourceRules(props: {
pageIndex: 0, pageIndex: 0,
pageSize: 25 pageSize: 25
}); });
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
@@ -574,302 +583,63 @@ export default function ResourceRules(props: {
return ( return (
<SettingsContainer> <SettingsContainer>
{/* <Alert className="hidden md:block"> */} {/* 1. Enabled Rules Control & How it works */}
{/* <InfoIcon className="h-4 w-4" /> */}
{/* <AlertTitle className="font-semibold">{t('rulesAbout')}</AlertTitle> */}
{/* <AlertDescription className="mt-4"> */}
{/* <div className="space-y-1 mb-4"> */}
{/* <p> */}
{/* {t('rulesAboutDescription')} */}
{/* </p> */}
{/* </div> */}
{/* <InfoSections cols={2}> */}
{/* <InfoSection> */}
{/* <InfoSectionTitle>{t('rulesActions')}</InfoSectionTitle> */}
{/* <ul className="text-sm text-muted-foreground space-y-1"> */}
{/* <li className="flex items-center gap-2"> */}
{/* <Check className="text-green-500 w-4 h-4" /> */}
{/* {t('rulesActionAlwaysAllow')} */}
{/* </li> */}
{/* <li className="flex items-center gap-2"> */}
{/* <X className="text-red-500 w-4 h-4" /> */}
{/* {t('rulesActionAlwaysDeny')} */}
{/* </li> */}
{/* </ul> */}
{/* </InfoSection> */}
{/* <InfoSection> */}
{/* <InfoSectionTitle> */}
{/* {t('rulesMatchCriteria')} */}
{/* </InfoSectionTitle> */}
{/* <ul className="text-sm text-muted-foreground space-y-1"> */}
{/* <li className="flex items-center gap-2"> */}
{/* {t('rulesMatchCriteriaIpAddress')} */}
{/* </li> */}
{/* <li className="flex items-center gap-2"> */}
{/* {t('rulesMatchCriteriaIpAddressRange')} */}
{/* </li> */}
{/* <li className="flex items-center gap-2"> */}
{/* {t('rulesMatchCriteriaUrl')} */}
{/* </li> */}
{/* </ul> */}
{/* </InfoSection> */}
{/* </InfoSections> */}
{/* </AlertDescription> */}
{/* </Alert> */}
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t('rulesResource')} {t('rulesEnable')}
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t('rulesResourceDescription')} {t('rulesEnableDescription')}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<div className="space-y-6"> <div className="flex items-center space-x-2">
<div className="flex items-center space-x-2"> <SwitchInput
<SwitchInput id="rules-toggle"
id="rules-toggle" label={t('rulesEnable')}
label={t('rulesEnable')} defaultChecked={rulesEnabled}
defaultChecked={rulesEnabled} onCheckedChange={(val) => setRulesEnabled(val)}
onCheckedChange={(val) => setRulesEnabled(val)} />
/> </div>
<div className="rounded-md border bg-muted/30 p-4">
<div className="mb-3 text-sm text-muted-foreground">
{t('rulesAboutDescription')}
</div> </div>
<InfoSections cols={2}>
<Form {...addRuleForm}> <InfoSection>
<form <InfoSectionTitle>{t('rulesActions')}</InfoSectionTitle>
onSubmit={addRuleForm.handleSubmit(addRule)} <ul className="text-sm text-muted-foreground space-y-1">
className="space-y-4" <li className="flex items-center gap-2">
> <Check className="text-green-500 w-4 h-4" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-end"> {t('rulesActionAlwaysAllow')}
<FormField </li>
control={addRuleForm.control} <li className="flex items-center gap-2">
name="action" <X className="text-red-500 w-4 h-4" />
render={({ field }) => ( {t('rulesActionAlwaysDeny')}
<FormItem> </li>
<FormLabel>{t('rulesAction')}</FormLabel> </ul>
<FormControl> </InfoSection>
<Select <InfoSection>
value={field.value} <InfoSectionTitle>{t('rulesMatchCriteria')}</InfoSectionTitle>
onValueChange={ <ul className="text-sm text-muted-foreground space-y-1">
field.onChange <li className="flex items-center gap-2">
} {t('rulesMatchCriteriaIpAddress')}
> </li>
<SelectTrigger className="w-full"> <li className="flex items-center gap-2">
<SelectValue /> {t('rulesMatchCriteriaIpAddressRange')}
</SelectTrigger> </li>
<SelectContent> <li className="flex items-center gap-2">
<SelectItem value="ACCEPT"> {t('rulesMatchCriteriaUrl')}
{RuleAction.ACCEPT} </li>
</SelectItem> </ul>
<SelectItem value="DROP"> </InfoSection>
{RuleAction.DROP} </InfoSections>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addRuleForm.control}
name="match"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesMatchType')}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{resource.http && (
<SelectItem value="PATH">
{RuleMatch.PATH}
</SelectItem>
)}
<SelectItem value="IP">
{RuleMatch.IP}
</SelectItem>
<SelectItem value="CIDR">
{RuleMatch.CIDR}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addRuleForm.control}
name="value"
render={({ field }) => (
<FormItem className="gap-1">
<InfoPopup
text={t('value')}
info={
getValueHelpText(
addRuleForm.watch(
"match"
)
) || ""
}
/>
<FormControl>
<Input {...field}/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
variant="secondary"
disabled={!rulesEnabled}
>
{t('ruleSubmit')}
</Button>
</div>
</form>
</Form>
<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"
>
{t('rulesNoOne')}
</TableCell>
</TableRow>
)}
</TableBody>
{/* <TableCaption> */}
{/* {t('rulesOrder')} */}
{/* </TableCaption> */}
</Table>
{/* Pagination Controls */}
{rules.length > 0 && (
<div className="flex items-center justify-between space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
Showing {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1} to{" "}
{Math.min(
(table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length
)}{" "}
of {table.getFilteredRowModel().rows.length} rules
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</div> </div>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
{/* Template Assignment Section */} {/* 2. Rule Templates Section */}
{rulesEnabled && ( {rulesEnabled && (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
@@ -881,21 +651,247 @@ export default function ResourceRules(props: {
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<ResourceRulesManager <ResourceRulesManager
resourceId={params.resourceId.toString()} resourceId={params.resourceId.toString()}
orgId={resource.orgId} orgId={resource.orgId}
onUpdate={fetchRules} onUpdate={fetchRules}
/> />
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
)} )}
{/* 3. Resource Rules Configuration */}
{rulesEnabled && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('rulesResource')}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('rulesResourceDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="flex justify-end">
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogTrigger asChild>
<Button variant="secondary">{t('ruleSubmit')}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('ruleSubmit')}</DialogTitle>
<DialogDescription>
{t('rulesResourceDescription')}
</DialogDescription>
</DialogHeader>
<Form {...addRuleForm}>
<form
onSubmit={addRuleForm.handleSubmit(async (data) => {
await addRule(data);
setCreateDialogOpen(false);
})}
className="space-y-4"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={addRuleForm.control}
name="action"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesAction')}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCEPT">{RuleAction.ACCEPT}</SelectItem>
<SelectItem value="DROP">{RuleAction.DROP}</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addRuleForm.control}
name="match"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesMatchType')}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{resource.http && (
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
)}
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addRuleForm.control}
name="value"
render={({ field }) => (
<FormItem className="gap-1 md:col-span-2">
<InfoPopup
text={t('value')}
info={getValueHelpText(addRuleForm.watch('match')) || ''}
/>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button type="submit">{t('ruleSubmit')}</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
<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">
{t('rulesNoOne')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{rules.length > 0 && (
<div className="flex items-center justify-between space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
Showing {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1} to{" "}
{Math.min(
(table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length
)}{" "}
of {table.getFilteredRowModel().rows.length} rules
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</SettingsSectionBody>
</SettingsSection>
)}
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button onClick={saveAllSettings} loading={loading} disabled={loading}>
onClick={saveAllSettings}
loading={loading}
disabled={loading}
>
{t('saveAllSettings')} {t('saveAllSettings')}
</Button> </Button>
</div> </div>

View File

@@ -12,9 +12,13 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { import {
SettingsContainer, SettingsContainer,
SettingsSection, SettingsSection,
SettingsSectionHeader SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionFooter,
SettingsSectionForm
} from "@app/components/Settings"; } from "@app/components/Settings";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { Textarea } from "@app/components/ui/textarea"; import { Textarea } from "@app/components/ui/textarea";
@@ -118,37 +122,44 @@ export default function GeneralPage() {
<SettingsContainer> <SettingsContainer>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle title={t("templateDetails")} /> <SettingsSectionTitle>
{t("templateDetails")}
</SettingsSectionTitle>
<SettingsSectionDescription>
Update the name and description for this rule template.
</SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <SettingsSectionBody>
<div> <SettingsSectionForm>
<label htmlFor="name" className="block text-sm font-medium mb-2"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4" id="template-general-form">
{t("name")} <div>
</label> <label htmlFor="name" className="block text-sm font-medium mb-2">
<Input {t("name")}
id="name" </label>
{...register("name")} <Input
className={errors.name ? "border-red-500" : ""} id="name"
/> {...register("name")}
{errors.name && ( className={errors.name ? "border-red-500" : ""}
<p className="text-red-500 text-sm mt-1">{errors.name.message}</p> />
)} {errors.name && (
</div> <p className="text-red-500 text-sm mt-1">{errors.name.message}</p>
<div> )}
<label htmlFor="description" className="block text-sm font-medium mb-2"> </div>
{t("description")} <div>
</label> <label htmlFor="description" className="block text-sm font-medium mb-2">
<Textarea {t("description")}
id="description" </label>
{...register("description")} <Textarea id="description" {...register("description")} rows={3} />
rows={3} </div>
/> </form>
</div> </SettingsSectionForm>
<Button type="submit" disabled={saving}> </SettingsSectionBody>
<SettingsSectionFooter>
<Button type="submit" form="template-general-form" disabled={saving}>
<Save className="w-4 h-4 mr-2" /> <Save className="w-4 h-4 mr-2" />
{saving ? t("saving") : t("save")} {saving ? t("saving") : t("save")}
</Button> </Button>
</form> </SettingsSectionFooter>
</SettingsSection> </SettingsSection>
</SettingsContainer> </SettingsContainer>
); );

View File

@@ -4,24 +4,35 @@ import { useParams } from "next/navigation";
import { import {
SettingsContainer, SettingsContainer,
SettingsSection, SettingsSection,
SettingsSectionHeader SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody
} from "@app/components/Settings"; } from "@app/components/Settings";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { TemplateRulesManager } from "@app/components/ruleTemplate/TemplateRulesManager"; import { TemplateRulesManager } from "@app/components/ruleTemplate/TemplateRulesManager";
import { useTranslations } from "next-intl";
export default function RulesPage() { export default function RulesPage() {
const params = useParams(); const params = useParams();
const t = useTranslations();
return ( return (
<SettingsContainer> <SettingsContainer>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle title="Template Rules" /> <SettingsSectionTitle>
{t('ruleTemplates')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Manage the rules for this template. Changes propagate to all assigned resources.
</SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<TemplateRulesManager <SettingsSectionBody>
orgId={params.orgId as string} <TemplateRulesManager
templateId={params.templateId as string} orgId={params.orgId as string}
/> templateId={params.templateId as string}
/>
</SettingsSectionBody>
</SettingsSection> </SettingsSection>
</SettingsContainer> </SettingsContainer>
); );

View File

@@ -2,7 +2,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
@@ -156,47 +155,43 @@ export function ResourceRulesManager({
} }
return ( return (
<div className="space-y-6"> <div className="space-y-4">
{/* Template Assignment */} <div className="space-y-2">
<Card> <div className="flex items-center gap-2">
<CardHeader> <Select
<CardTitle>Template Assignment</CardTitle> value={selectedTemplate}
<CardDescription> onValueChange={(value) => {
Assign rule templates to this resource for consistent access control setSelectedTemplate(value);
</CardDescription> handleAssignTemplate(value);
</CardHeader> }}
<CardContent className="space-y-4"> >
<div className="flex items-center space-x-2"> <SelectTrigger className="w-64">
<Select <SelectValue placeholder="Select a template to assign" />
value={selectedTemplate} </SelectTrigger>
onValueChange={(value) => { <SelectContent>
setSelectedTemplate(value); {templates.map((template) => (
handleAssignTemplate(value); <SelectItem key={template.templateId} value={template.templateId}>
}} {template.name}
> </SelectItem>
<SelectTrigger className="w-64"> ))}
<SelectValue placeholder="Select a template to assign" /> </SelectContent>
</SelectTrigger> </Select>
<SelectContent> </div>
{templates.map((template) => (
<SelectItem key={template.templateId} value={template.templateId}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{resourceTemplates.length > 0 && ( {resourceTemplates.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium">Assigned Templates</h4>
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium">Assigned Templates</h4>
{resourceTemplates.map((template) => ( {resourceTemplates.map((template) => (
<div key={template.templateId} className="flex items-center justify-between p-3 border rounded-lg"> <div
<div className="flex items-center space-x-2"> key={template.templateId}
className="flex items-center justify-between p-3 border rounded-md bg-muted/30"
>
<div className="flex items-center gap-2">
<span className="font-medium">{template.name}</span> <span className="font-medium">{template.name}</span>
<span className="text-sm text-muted-foreground"> {template.description && (
{template.description} <span className="text-sm text-muted-foreground">{template.description}</span>
</span> )}
</div> </div>
<Button <Button
variant="outline" variant="outline"
@@ -209,9 +204,9 @@ export function ResourceRulesManager({
</div> </div>
))} ))}
</div> </div>
)} </div>
</CardContent> )}
</Card> </div>
<ConfirmationDialog <ConfirmationDialog
open={unassignDialogOpen} open={unassignDialogOpen}

View File

@@ -47,6 +47,15 @@ import {
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "@server/lib/validators"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "@server/lib/validators";
import { ArrowUpDown, Trash2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; import { ArrowUpDown, Trash2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { ConfirmationDialog } from "@app/components/ConfirmationDialog"; import { ConfirmationDialog } from "@app/components/ConfirmationDialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@app/components/ui/dialog";
const addRuleSchema = z.object({ const addRuleSchema = z.object({
action: z.enum(["ACCEPT", "DROP"]), action: z.enum(["ACCEPT", "DROP"]),
@@ -76,6 +85,7 @@ export function TemplateRulesManager({ templateId, orgId }: TemplateRulesManager
const [rules, setRules] = useState<TemplateRule[]>([]); const [rules, setRules] = useState<TemplateRule[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [addingRule, setAddingRule] = useState(false); const [addingRule, setAddingRule] = useState(false);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
pageIndex: 0, pageIndex: 0,
pageSize: 25 pageSize: 25
@@ -366,107 +376,116 @@ export function TemplateRulesManager({ templateId, orgId }: TemplateRulesManager
}); });
if (loading) { if (loading) {
return <div>Loading rules...</div>; return <div className="text-muted-foreground">Loading...</div>;
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Form {...form}> <div className="flex justify-end">
<form onSubmit={form.handleSubmit(addRule)} className="space-y-4"> <Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-end"> <DialogTrigger asChild>
<FormField <Button variant="secondary" disabled={addingRule}>
control={form.control} {addingRule ? "Adding Rule..." : t('ruleSubmit')}
name="action" </Button>
render={({ field }) => ( </DialogTrigger>
<FormItem> <DialogContent>
<FormLabel>{t('rulesAction')}</FormLabel> <DialogHeader>
<FormControl> <DialogTitle>{t('ruleSubmit')}</DialogTitle>
<Select <DialogDescription>
value={field.value} {t('rulesResourceDescription')}
onValueChange={field.onChange} </DialogDescription>
> </DialogHeader>
<SelectTrigger> <Form {...form}>
<SelectValue /> <form
</SelectTrigger> onSubmit={form.handleSubmit(async (data) => {
<SelectContent> await addRule(data);
<SelectItem value="ACCEPT"> setCreateDialogOpen(false);
{RuleAction.ACCEPT} })}
</SelectItem> className="space-y-4"
<SelectItem value="DROP">{RuleAction.DROP}</SelectItem> >
</SelectContent> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
</Select> <FormField
</FormControl> control={form.control}
<FormMessage /> name="action"
</FormItem> render={({ field }) => (
)} <FormItem>
/> <FormLabel>{t('rulesAction')}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCEPT">{RuleAction.ACCEPT}</SelectItem>
<SelectItem value="DROP">{RuleAction.DROP}</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="match"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesMatchType')}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="value"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>{t('value')}</FormLabel>
<FormControl>
<Input placeholder="Enter value" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesPriority')} (optional)</FormLabel>
<FormControl>
<Input type="number" placeholder="Auto" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button type="submit" variant="secondary" disabled={addingRule}>
{addingRule ? "Adding Rule..." : t('ruleSubmit')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
<FormField <div>
control={form.control}
name="match"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesMatchType')}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="value"
render={({ field }) => (
<FormItem>
<FormLabel>{t('value')}</FormLabel>
<FormControl>
<Input placeholder="Enter value" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesPriority')} (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> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (