Compare commits

..

31 Commits

Author SHA1 Message Date
Owen
7fa44981cf Merge branch 'adrianeastles-feature/resource-rule-templates' into policies 2025-10-06 20:55:04 -07:00
Owen
1bacad7854 Merge branch 'feature/resource-rule-templates' of github.com:adrianeastles/pangolin into adrianeastles-feature/resource-rule-templates 2025-10-06 20:54:43 -07:00
Owen
b627e391ac Add tsc test 2025-10-06 11:29:34 -07:00
Owen
40a3eac704 Adjust tag match to exclude s. 2025-10-06 11:28:26 -07:00
dependabot[bot]
2d30b155f2 Bump @types/node from 24.6.1 to 24.6.2 in the dev-patch-updates group
Bumps the dev-patch-updates group with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@types/node` from 24.6.1 to 24.6.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.6.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-06 09:55:35 -07:00
dependabot[bot]
1333e21553 Bump @react-email/preview-server in the dev-minor-updates group
Bumps the dev-minor-updates group with 1 update: [@react-email/preview-server](https://github.com/resend/react-email/tree/HEAD/packages/preview-server).


Updates `@react-email/preview-server` from 4.1.0 to 4.2.12
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/preview-server/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/@react-email/preview-server@4.2.12/packages/preview-server)

---
updated-dependencies:
- dependency-name: "@react-email/preview-server"
  dependency-version: 4.2.12
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-06 09:55:35 -07:00
Owen
4c412528f5 Clean up and copy to getTraefikConfig 2025-10-06 09:55:35 -07:00
OddMagnet
a8fce47ba0 Update traefik dynamic config to also use resource name 2025-10-06 09:55:35 -07:00
Owen Schwartz
4447fb8202 Merge pull request #1459 from SigmaSquadron/revert-1281-push-nymutulytrsq
Revert "fix: change default integration_api to 3004"
2025-10-05 17:41:15 -07:00
Owen Schwartz
1c9c4b1802 Merge pull request #1619 from fosrl/crowdin_dev
New Crowdin updates
2025-10-05 17:19:23 -07:00
Owen Schwartz
19e15f4ef5 New translations en-us.json (Chinese Simplified) 2025-10-05 17:16:34 -07:00
Owen Schwartz
c2c29e2cd2 New translations en-us.json (Portuguese) 2025-10-05 17:16:31 -07:00
Owen Schwartz
7b33dc591d New translations en-us.json (Dutch) 2025-10-05 17:16:29 -07:00
Owen Schwartz
a95f2e76f4 New translations en-us.json (Italian) 2025-10-05 17:16:27 -07:00
Owen Schwartz
979860a951 New translations en-us.json (Czech) 2025-10-05 17:16:25 -07:00
Owen Schwartz
9e9a81d9e8 New translations en-us.json (Bulgarian) 2025-10-05 17:16:24 -07:00
Owen Schwartz
8f09561114 Merge pull request #1592 from Pallavikumarimdb/ordered-priority-in-path-routing-rules
Add ordered priority for path-based routing rules
2025-10-05 17:10:26 -07:00
miloschwartz
b167d94ead update cors to check array 2025-10-05 16:50:46 -07:00
Owen
e4c0a157e3 Add to oss traefik config and fix create/update 2025-10-05 15:46:46 -07:00
Pallavi Kumari
22477b7e81 add removed rewrite schema 2025-10-06 02:16:06 +05:30
Pallavi Kumari
b6c76a2164 add priority type 2025-10-06 02:08:41 +05:30
Pallavi Kumari
043834274d fix priority inside blueprints 2025-10-06 02:08:41 +05:30
Owen
1e4ca69c89 priority add for traefik config setup 2025-10-06 02:08:41 +05:30
Owen
ff2bcfb0e7 backend setup 2025-10-06 02:08:41 +05:30
Owen
b47fc9f901 frontend for ordered priority 2025-10-06 02:08:41 +05:30
Fernando Rodrigues
ee8952de10 Revert "fix: change default integration_api to 3004" 2025-09-14 13:07:08 +00:00
Adrian Astles
75cec731e8 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
2025-08-08 19:30:26 +08:00
Adrian Astles
16a88281bb Added better notifications for users when templates are updated.
Added confirmation dialogs for destructive actions.
Other improvements/changes
When deleting rule templates, we now clean up all resource rules that were created from the template.
2025-08-07 23:49:56 +08:00
Adrian Astles
1574cbc5df Pagination for template rules table and resource rules table. 2025-08-07 23:23:20 +08:00
Adrian Astles
2cb2a115b0 align template rules table columns with resource rules page 2025-08-07 23:14:24 +08:00
Adrian Astles
9dce7b2cde 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
2025-08-07 22:57:18 +08:00
56 changed files with 4935 additions and 190 deletions

View File

@@ -3,7 +3,7 @@ name: CI/CD Pipeline
on:
push:
tags:
- "*"
- "[0-9]+.[0-9]+.[0-9]+"
jobs:
release:

View File

@@ -35,6 +35,9 @@ jobs:
- name: Apply database migrations
run: npm run db:sqlite:push
- name: Test with tsc
run: npx tsc --noEmit
- name: Start app in background
run: nohup npm run dev &

View File

@@ -1333,7 +1333,7 @@
"twoFactorRequired": "Двуфакторното удостоверяване е необходимо за регистрация на ключ за защита.",
"twoFactor": "Двуфакторно удостоверяване",
"adminEnabled2FaOnYourAccount": "Вашият администратор е активирал двуфакторно удостоверяване за {email}. Моля, завършете процеса по настройка, за да продължите.",
"continueToApplication": "Продължаване към приложението",
"continueToApplication": "Продължете към приложението",
"securityKeyAdd": "Добавяне на ключ за сигурност",
"securityKeyRegisterTitle": "Регистриране на нов ключ за сигурност",
"securityKeyRegisterDescription": "Свържете ключа за сигурност и въведете име, по което да го идентифицирате",

View File

@@ -1333,7 +1333,7 @@
"twoFactorRequired": "Pro registraci bezpečnostního klíče je nutné dvoufaktorové ověření.",
"twoFactor": "Dvoufaktorové ověření",
"adminEnabled2FaOnYourAccount": "Váš správce povolil dvoufaktorové ověřování pro {email}. Chcete-li pokračovat, dokončete proces nastavení.",
"continueToApplication": "Pokračovat do aplikace",
"continueToApplication": "Pokračovat v aplikaci",
"securityKeyAdd": "Přidat bezpečnostní klíč",
"securityKeyRegisterTitle": "Registrovat nový bezpečnostní klíč",
"securityKeyRegisterDescription": "Připojte svůj bezpečnostní klíč a zadejte jméno pro jeho identifikaci",

View File

@@ -1041,6 +1041,26 @@
"actionDeleteResourceRule": "Delete Resource Rule",
"actionListResourceRules": "List Resource Rules",
"actionUpdateResourceRule": "Update Resource Rule",
"ruleTemplates": "Rule Templates",
"ruleTemplatesDescription": "Assign rule templates to automatically apply consistent rules across multiple resources",
"ruleTemplatesSearch": "Search templates...",
"ruleTemplateAdd": "Create Template",
"ruleTemplateErrorDelete": "Failed to delete template",
"ruleTemplateCreated": "Template created",
"ruleTemplateCreatedDescription": "Rule template created successfully",
"ruleTemplateErrorCreate": "Failed to create template",
"ruleTemplateErrorCreateDescription": "An error occurred while creating the template",
"ruleTemplateSetting": "Rule Template Settings",
"ruleTemplateSettingDescription": "Manage template details and rules",
"ruleTemplateErrorLoad": "Failed to load template",
"ruleTemplateErrorLoadDescription": "An error occurred while loading the template",
"ruleTemplateUpdated": "Template updated",
"ruleTemplateUpdatedDescription": "Template updated successfully",
"ruleTemplateErrorUpdate": "Failed to update template",
"ruleTemplateErrorUpdateDescription": "An error occurred while updating the template",
"save": "Save",
"saving": "Saving...",
"templateDetails": "Template Details",
"actionListOrgs": "List Organizations",
"actionCheckOrgId": "Check ID",
"actionCreateOrg": "Create Organization",
@@ -1136,6 +1156,7 @@
"sidebarInvitations": "Invitations",
"sidebarRoles": "Roles",
"sidebarShareableLinks": "Shareable Links",
"sidebarRuleTemplates": "Rule Templates",
"sidebarApiKeys": "API Keys",
"sidebarSettings": "Settings",
"sidebarAllUsers": "All Users",

View File

@@ -1333,7 +1333,7 @@
"twoFactorRequired": "È richiesta l'autenticazione a due fattori per registrare una chiave di sicurezza.",
"twoFactor": "Autenticazione a Due Fattori",
"adminEnabled2FaOnYourAccount": "Il tuo amministratore ha abilitato l'autenticazione a due fattori per {email}. Completa il processo di configurazione per continuare.",
"continueToApplication": "Continua all'Applicazione",
"continueToApplication": "Continua con l'applicazione",
"securityKeyAdd": "Aggiungi Chiave di Sicurezza",
"securityKeyRegisterTitle": "Registra Nuova Chiave di Sicurezza",
"securityKeyRegisterDescription": "Collega la tua chiave di sicurezza e inserisci un nome per identificarla",

View File

@@ -1333,7 +1333,7 @@
"twoFactorRequired": "Tweestapsverificatie is vereist om een beveiligingssleutel te registreren.",
"twoFactor": "Tweestapsverificatie",
"adminEnabled2FaOnYourAccount": "Je beheerder heeft tweestapsverificatie voor {email} ingeschakeld. Voltooi het instellingsproces om verder te gaan.",
"continueToApplication": "Doorgaan naar de applicatie",
"continueToApplication": "Doorgaan naar applicatie",
"securityKeyAdd": "Beveiligingssleutel toevoegen",
"securityKeyRegisterTitle": "Nieuwe beveiligingssleutel registreren",
"securityKeyRegisterDescription": "Verbind je beveiligingssleutel en voer een naam in om deze te identificeren",

View File

@@ -1333,7 +1333,7 @@
"twoFactorRequired": "A autenticação de dois fatores é necessária para registrar uma chave de segurança.",
"twoFactor": "Autenticação de Dois Fatores",
"adminEnabled2FaOnYourAccount": "Seu administrador ativou a autenticação de dois fatores para {email}. Complete o processo de configuração para continuar.",
"continueToApplication": "Continuar para Aplicativo",
"continueToApplication": "Continuar para o aplicativo",
"securityKeyAdd": "Adicionar Chave de Segurança",
"securityKeyRegisterTitle": "Registrar Nova Chave de Segurança",
"securityKeyRegisterDescription": "Conecte sua chave de segurança e insira um nome para identificá-la",

View File

@@ -1333,7 +1333,7 @@
"twoFactorRequired": "注册安全密钥需要两步验证。",
"twoFactor": "两步验证",
"adminEnabled2FaOnYourAccount": "管理员已为{email}启用两步验证。请完成设置以继续。",
"continueToApplication": "继续应用程序",
"continueToApplication": "继续应用",
"securityKeyAdd": "添加安全密钥",
"securityKeyRegisterTitle": "注册新安全密钥",
"securityKeyRegisterDescription": "连接您的安全密钥并输入名称以便识别",

1219
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -128,7 +128,7 @@
"devDependencies": {
"@dotenvx/dotenvx": "1.51.0",
"@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/preview-server": "4.1.0",
"@react-email/preview-server": "4.2.12",
"@tailwindcss/postcss": "^4.1.14",
"@types/better-sqlite3": "7.6.12",
"@types/cookie-parser": "1.4.9",
@@ -139,7 +139,7 @@
"@types/jmespath": "^0.15.2",
"@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "24.6.1",
"@types/node": "24.6.2",
"@types/nodemailer": "7.0.2",
"@types/pg": "8.15.5",
"@types/react": "19.1.16",

View File

@@ -125,7 +125,8 @@ export const targets = pgTable("targets", {
path: text("path"),
pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType") // exact, prefix, regex, stripPrefix
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
priority: integer("priority").notNull().default(100)
});
export const targetHealthCheck = pgTable("targetHealthCheck", {
@@ -465,6 +466,8 @@ export const resourceRules = pgTable("resourceRules", {
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
templateRuleId: integer("templateRuleId")
.references(() => templateRules.ruleId, { onDelete: "cascade" }),
enabled: boolean("enabled").notNull().default(true),
priority: integer("priority").notNull(),
action: varchar("action").notNull(), // ACCEPT, DROP, PASS
@@ -472,6 +475,40 @@ export const resourceRules = pgTable("resourceRules", {
value: varchar("value").notNull()
});
// Rule templates (reusable rule sets)
export const ruleTemplates = pgTable("ruleTemplates", {
templateId: varchar("templateId").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
name: varchar("name").notNull(),
description: varchar("description"),
createdAt: bigint("createdAt", { mode: "number" }).notNull()
});
// Rules within templates
export const templateRules = pgTable("templateRules", {
ruleId: serial("ruleId").primaryKey(),
templateId: varchar("templateId")
.notNull()
.references(() => ruleTemplates.templateId, { onDelete: "cascade" }),
enabled: boolean("enabled").notNull().default(true),
priority: integer("priority").notNull(),
action: varchar("action").notNull(), // ACCEPT, DROP
match: varchar("match").notNull(), // CIDR, IP, PATH
value: varchar("value").notNull()
});
// Template assignments to resources
export const resourceTemplates = pgTable("resourceTemplates", {
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
templateId: varchar("templateId")
.notNull()
.references(() => ruleTemplates.templateId, { onDelete: "cascade" })
});
export const supporterKey = pgTable("supporterKey", {
keyId: serial("keyId").primaryKey(),
key: varchar("key").notNull(),
@@ -710,4 +747,7 @@ export type OrgDomains = InferSelectModel<typeof orgDomains>;
export type SiteResource = InferSelectModel<typeof siteResources>;
export type SetupToken = InferSelectModel<typeof setupTokens>;
export type HostMeta = InferSelectModel<typeof hostMeta>;
export type TargetHealthCheck = InferSelectModel<typeof targetHealthCheck>;
export type TargetHealthCheck = InferSelectModel<typeof targetHealthCheck>;
export type RuleTemplate = InferSelectModel<typeof ruleTemplates>;
export type TemplateRule = InferSelectModel<typeof templateRules>;
export type ResourceTemplate = InferSelectModel<typeof resourceTemplates>;

View File

@@ -137,7 +137,8 @@ export const targets = sqliteTable("targets", {
path: text("path"),
pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType") // exact, prefix, regex, stripPrefix
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
priority: integer("priority").notNull().default(100)
});
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
@@ -599,6 +600,8 @@ export const resourceRules = sqliteTable("resourceRules", {
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
templateRuleId: integer("templateRuleId")
.references(() => templateRules.ruleId, { onDelete: "cascade" }),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull(),
action: text("action").notNull(), // ACCEPT, DROP, PASS
@@ -606,6 +609,40 @@ export const resourceRules = sqliteTable("resourceRules", {
value: text("value").notNull()
});
// Rule templates (reusable rule sets)
export const ruleTemplates = sqliteTable("ruleTemplates", {
templateId: text("templateId").primaryKey(),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
createdAt: integer("createdAt").notNull()
});
// Rules within templates
export const templateRules = sqliteTable("templateRules", {
ruleId: integer("ruleId").primaryKey({ autoIncrement: true }),
templateId: text("templateId")
.notNull()
.references(() => ruleTemplates.templateId, { onDelete: "cascade" }),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull(),
action: text("action").notNull(), // ACCEPT, DROP
match: text("match").notNull(), // CIDR, IP, PATH
value: text("value").notNull()
});
// Template assignments to resources
export const resourceTemplates = sqliteTable("resourceTemplates", {
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
templateId: text("templateId")
.notNull()
.references(() => ruleTemplates.templateId, { onDelete: "cascade" })
});
export const supporterKey = sqliteTable("supporterKey", {
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
key: text("key").notNull(),
@@ -747,4 +784,7 @@ export type SiteResource = InferSelectModel<typeof siteResources>;
export type OrgDomains = InferSelectModel<typeof orgDomains>;
export type SetupToken = InferSelectModel<typeof setupTokens>;
export type HostMeta = InferSelectModel<typeof hostMeta>;
export type TargetHealthCheck = InferSelectModel<typeof targetHealthCheck>;
export type TargetHealthCheck = InferSelectModel<typeof targetHealthCheck>;
export type RuleTemplate = InferSelectModel<typeof ruleTemplates>;
export type TemplateRule = InferSelectModel<typeof templateRules>;
export type ResourceTemplate = InferSelectModel<typeof resourceTemplates>;

View File

@@ -114,7 +114,8 @@ export async function updateProxyResources(
path: targetData.path,
pathMatchType: targetData["path-match"],
rewritePath: targetData.rewritePath,
rewritePathType: targetData["rewrite-match"]
rewritePathType: targetData["rewrite-match"],
priority: targetData.priority
})
.returning();
@@ -363,7 +364,8 @@ export async function updateProxyResources(
path: targetData.path,
pathMatchType: targetData["path-match"],
rewritePath: targetData.rewritePath,
rewritePathType: targetData["rewrite-match"]
rewritePathType: targetData["rewrite-match"],
priority: targetData.priority
})
.where(eq(targets.targetId, existingTarget.targetId))
.returning();

View File

@@ -33,7 +33,8 @@ export const TargetSchema = z.object({
"path-match": z.enum(["exact", "prefix", "regex"]).optional().nullable(),
healthcheck: TargetHealthCheckSchema.optional(),
rewritePath: z.string().optional(),
"rewrite-match": z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable()
"rewrite-match": z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable(),
priority: z.number().int().min(1).max(1000).optional().default(100)
});
export type TargetData = z.infer<typeof TargetSchema>;

View File

@@ -64,7 +64,7 @@ export const configSchema = z
server: z.object({
integration_port: portSchema
.optional()
.default(3004)
.default(3003)
.transform(stoi)
.pipe(portSchema.optional()),
external_port: portSchema

View File

@@ -1,81 +1,15 @@
import { db, exitNodes, targetHealthCheck } from "@server/db";
import { and, eq, inArray, or, isNull, ne, isNotNull } from "drizzle-orm";
import { and, eq, inArray, or, isNull, ne, isNotNull, desc } from "drizzle-orm";
import logger from "@server/logger";
import config from "@server/lib/config";
import { orgs, resources, sites, Target, targets } from "@server/db";
import { build } from "@server/build";
import createPathRewriteMiddleware from "./middleware";
import { sanitize, validatePathRewriteConfig } from "./utils";
const redirectHttpsMiddlewareName = "redirect-to-https";
const badgerMiddlewareName = "badger";
function validatePathRewriteConfig(
path: string | null,
pathMatchType: string | null,
rewritePath: string | null,
rewritePathType: string | null
): { isValid: boolean; error?: string } {
// If no path matching is configured, no rewriting is possible
if (!path || !pathMatchType) {
if (rewritePath || rewritePathType) {
return {
isValid: false,
error: "Path rewriting requires path matching to be configured"
};
}
return { isValid: true };
}
if (rewritePathType !== "stripPrefix") {
if ((rewritePath && !rewritePathType) || (!rewritePath && rewritePathType)) {
return { isValid: false, error: "Both rewritePath and rewritePathType must be specified together" };
}
}
if (!rewritePath || !rewritePathType) {
return { isValid: true };
}
const validPathMatchTypes = ["exact", "prefix", "regex"];
if (!validPathMatchTypes.includes(pathMatchType)) {
return {
isValid: false,
error: `Invalid pathMatchType: ${pathMatchType}. Must be one of: ${validPathMatchTypes.join(", ")}`
};
}
const validRewritePathTypes = ["exact", "prefix", "regex", "stripPrefix"];
if (!validRewritePathTypes.includes(rewritePathType)) {
return {
isValid: false,
error: `Invalid rewritePathType: ${rewritePathType}. Must be one of: ${validRewritePathTypes.join(", ")}`
};
}
if (pathMatchType === "regex") {
try {
new RegExp(path);
} catch (e) {
return {
isValid: false,
error: `Invalid regex pattern in path: ${path}`
};
}
}
// Additional validation for stripPrefix
if (rewritePathType === "stripPrefix") {
if (pathMatchType !== "prefix") {
logger.warn(`stripPrefix rewrite type is most effective with prefix path matching. Current match type: ${pathMatchType}`);
}
}
return { isValid: true };
}
export async function getTraefikConfig(
exitNodeId: number,
siteTypes: string[],
@@ -99,6 +33,7 @@ export async function getTraefikConfig(
.select({
// Resource fields
resourceId: resources.resourceId,
resourceName: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
http: resources.http,
@@ -124,6 +59,8 @@ export async function getTraefikConfig(
pathMatchType: targets.pathMatchType,
rewritePath: targets.rewritePath,
rewritePathType: targets.rewritePathType,
priority: targets.priority,
// Site fields
siteId: sites.siteId,
siteType: sites.type,
@@ -152,25 +89,29 @@ export async function getTraefikConfig(
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
: eq(resources.http, true)
)
);
)
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
// Group by resource and include targets with their unique site data
const resourcesMap = new Map();
resourcesWithTargetsAndSites.forEach((row) => {
const resourceId = row.resourceId;
const targetPath = sanitizePath(row.path) || ""; // Handle null/undefined paths
const resourceName = sanitize(row.resourceName) || "";
const targetPath = sanitize(row.path) || ""; // Handle null/undefined paths
const pathMatchType = row.pathMatchType || "";
const rewritePath = row.rewritePath || "";
const rewritePathType = row.rewritePathType || "";
const priority = row.priority ?? 100;
// Create a unique key combining resourceId, path config, and rewrite config
const pathKey = [targetPath, pathMatchType, rewritePath, rewritePathType]
.filter(Boolean)
.join("-");
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
const key = sanitize(mapKey);
if (!resourcesMap.has(mapKey)) {
if (!resourcesMap.has(key)) {
const validation = validatePathRewriteConfig(
row.path,
row.pathMatchType,
@@ -183,8 +124,9 @@ export async function getTraefikConfig(
return;
}
resourcesMap.set(mapKey, {
resourcesMap.set(key, {
resourceId: row.resourceId,
name: resourceName,
fullDomain: row.fullDomain,
ssl: row.ssl,
http: row.http,
@@ -202,12 +144,13 @@ export async function getTraefikConfig(
path: row.path, // the targets will all have the same path
pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType
rewritePath: row.rewritePath,
rewritePathType: row.rewritePathType
rewritePathType: row.rewritePathType,
priority: priority // may be null, we fallback later
});
}
// Add target with its associated site data
resourcesMap.get(mapKey).targets.push({
resourcesMap.get(key).targets.push({
resourceId: row.resourceId,
targetId: row.targetId,
ip: row.ip,
@@ -217,6 +160,7 @@ export async function getTraefikConfig(
enabled: row.targetEnabled,
rewritePath: row.rewritePath,
rewritePathType: row.rewritePathType,
priority: row.priority,
site: {
siteId: row.siteId,
type: row.siteType,
@@ -248,13 +192,11 @@ export async function getTraefikConfig(
for (const [key, resource] of resourcesMap.entries()) {
const targets = resource.targets;
const sanatizedKey = sanitizeForMiddlewareName(key);
const routerName = `${sanatizedKey}-router`;
const serviceName = `${sanatizedKey}-service`;
const routerName = `${key}-${resource.name}-router`;
const serviceName = `${key}-${resource.name}-service`;
const fullDomain = `${resource.fullDomain}`;
const transportName = `${sanatizedKey}-transport`;
const headersMiddlewareName = `${sanatizedKey}-headers-middleware`;
const transportName = `${key}-transport`;
const headersMiddlewareName = `${key}-headers-middleware`;
if (!resource.enabled) {
continue;
@@ -328,7 +270,7 @@ export async function getTraefikConfig(
resource.rewritePathType) {
// Create a unique middleware name
const rewriteMiddlewareName = `rewrite-r${resource.resourceId}-${sanitizeForMiddlewareName(key)}`;
const rewriteMiddlewareName = `rewrite-r${resource.resourceId}-${key}`;
try {
const rewriteResult = createPathRewriteMiddleware(
@@ -402,10 +344,30 @@ export async function getTraefikConfig(
// Build routing rules
let rule = `Host(\`${fullDomain}\`)`;
let priority = 100;
// priority logic
let priority: number;
if (resource.priority && resource.priority != 100) {
priority = resource.priority;
} else {
priority = 100;
if (resource.path && resource.pathMatchType) {
priority += 10;
if (resource.pathMatchType === "exact") {
priority += 5;
} else if (resource.pathMatchType === "prefix") {
priority += 3;
} else if (resource.pathMatchType === "regex") {
priority += 2;
}
if (resource.path === "/") {
priority = 1; // lowest for catch-all
}
}
}
if (resource.path && resource.pathMatchType) {
priority += 1;
// priority += 1;
// add path to rule based on match type
let path = resource.path;
// if the path doesn't start with a /, add it
@@ -642,24 +604,3 @@ export async function getTraefikConfig(
}
return config_output;
}
function sanitizePath(path: string | null | undefined): string | undefined {
if (!path) return undefined;
const trimmed = path.trim();
if (!trimmed) return undefined;
// Preserve path structure for rewriting, only warn if very long
if (trimmed.length > 1000) {
logger.warn(`Path exceeds 1000 characters: ${trimmed.substring(0, 100)}...`);
return trimmed.substring(0, 1000);
}
return trimmed;
}
function sanitizeForMiddlewareName(str: string): string {
// Replace any characters that aren't alphanumeric or dash with dash
// and remove consecutive dashes
return str.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
}

View File

@@ -11,7 +11,6 @@
* This file is not licensed under the AGPLv3.
*/
import { Request, Response } from "express";
import {
certificates,
db,
@@ -20,12 +19,13 @@ import {
loginPage,
targetHealthCheck
} from "@server/db";
import { and, eq, inArray, or, isNull, ne, isNotNull } from "drizzle-orm";
import { and, eq, inArray, or, isNull, ne, isNotNull, desc } from "drizzle-orm";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import config from "@server/lib/config";
import { orgs, resources, sites, Target, targets } from "@server/db";
import { build } from "@server/build";
import { sanitize } from "./utils";
const redirectHttpsMiddlewareName = "redirect-to-https";
const redirectToRootMiddlewareName = "redirect-to-root";
@@ -54,6 +54,7 @@ export async function getTraefikConfig(
.select({
// Resource fields
resourceId: resources.resourceId,
resourceName: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
http: resources.http,
@@ -77,7 +78,8 @@ export async function getTraefikConfig(
hcHealth: targetHealthCheck.hcHealth,
path: targets.path,
pathMatchType: targets.pathMatchType,
priority: targets.priority,
// Site fields
siteId: sites.siteId,
siteType: sites.type,
@@ -118,15 +120,18 @@ export async function getTraefikConfig(
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
: eq(resources.http, true)
)
);
)
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
// Group by resource and include targets with their unique site data
const resourcesMap = new Map();
resourcesWithTargetsAndSites.forEach((row) => {
const resourceId = row.resourceId;
const targetPath = sanitizePath(row.path) || ""; // Handle null/undefined paths
const resourceName = sanitize(row.resourceName) || "";
const targetPath = sanitize(row.path) || ""; // Handle null/undefined paths
const pathMatchType = row.pathMatchType || "";
const priority = row.priority ?? 100;
if (filterOutNamespaceDomains && row.domainNamespaceId) {
return;
@@ -135,10 +140,12 @@ export async function getTraefikConfig(
// Create a unique key combining resourceId and path+pathMatchType
const pathKey = [targetPath, pathMatchType].filter(Boolean).join("-");
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
const key = sanitize(mapKey);
if (!resourcesMap.has(mapKey)) {
resourcesMap.set(mapKey, {
if (!resourcesMap.has(key)) {
resourcesMap.set(key, {
resourceId: row.resourceId,
name: resourceName,
fullDomain: row.fullDomain,
ssl: row.ssl,
http: row.http,
@@ -155,12 +162,13 @@ export async function getTraefikConfig(
targets: [],
headers: row.headers,
path: row.path, // the targets will all have the same path
pathMatchType: row.pathMatchType // the targets will all have the same pathMatchType
pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType
priority: priority // may be null, we fallback later
});
}
// Add target with its associated site data
resourcesMap.get(mapKey).targets.push({
resourcesMap.get(key).targets.push({
resourceId: row.resourceId,
targetId: row.targetId,
ip: row.ip,
@@ -168,6 +176,7 @@ export async function getTraefikConfig(
port: row.port,
internalPort: row.internalPort,
enabled: row.targetEnabled,
priority: row.priority,
site: {
siteId: row.siteId,
type: row.siteType,
@@ -206,8 +215,8 @@ export async function getTraefikConfig(
for (const [key, resource] of resourcesMap.entries()) {
const targets = resource.targets;
const routerName = `${key}-router`;
const serviceName = `${key}-service`;
const routerName = `${key}-${resource.name}-router`;
const serviceName = `${key}-${resource.name}-service`;
const fullDomain = `${resource.fullDomain}`;
const transportName = `${key}-transport`;
const headersMiddlewareName = `${key}-headers-middleware`;
@@ -331,9 +340,30 @@ export async function getTraefikConfig(
}
let rule = `Host(\`${fullDomain}\`)`;
let priority = 100;
// priority logic
let priority: number;
if (resource.priority && resource.priority != 100) {
priority = resource.priority;
} else {
priority = 100;
if (resource.path && resource.pathMatchType) {
priority += 10;
if (resource.pathMatchType === "exact") {
priority += 5;
} else if (resource.pathMatchType === "prefix") {
priority += 3;
} else if (resource.pathMatchType === "regex") {
priority += 2;
}
if (resource.path === "/") {
priority = 1; // lowest for catch-all
}
}
}
if (resource.path && resource.pathMatchType) {
priority += 1;
//priority += 1;
// add path to rule based on match type
let path = resource.path;
// if the path doesn't start with a /, add it
@@ -389,7 +419,7 @@ export async function getTraefikConfig(
return (
(targets as TargetWithSite[])
.filter((target: TargetWithSite) => {
.filter((target: TargetWithSite) => {
if (!target.enabled) {
return false;
}
@@ -410,7 +440,7 @@ export async function getTraefikConfig(
) {
return false;
}
} else if (target.site.type === "newt") {
} else if (target.site.type === "newt") {
if (
!target.internalPort ||
!target.method ||
@@ -418,10 +448,10 @@ export async function getTraefikConfig(
) {
return false;
}
}
return true;
})
.map((target: TargetWithSite) => {
}
return true;
})
.map((target: TargetWithSite) => {
if (
target.site.type === "local" ||
target.site.type === "wireguard"
@@ -429,14 +459,14 @@ export async function getTraefikConfig(
return {
url: `${target.method}://${target.ip}:${target.port}`
};
} else if (target.site.type === "newt") {
} else if (target.site.type === "newt") {
const ip =
target.site.subnet!.split("/")[0];
return {
url: `${target.method}://${ip}:${target.internalPort}`
};
}
})
}
})
// filter out duplicates
.filter(
(v, i, a) =>
@@ -679,14 +709,4 @@ export async function getTraefikConfig(
}
return config_output;
}
function sanitizePath(path: string | null | undefined): string | undefined {
if (!path) return undefined;
// clean any non alphanumeric characters from the path and replace with dashes
// the path cant be too long either, so limit to 50 characters
if (path.length > 50) {
path = path.substring(0, 50);
}
return path.replace(/[^a-zA-Z0-9]/g, "");
}
}

View File

@@ -0,0 +1,81 @@
import logger from "@server/logger";
export function sanitize(input: string | null | undefined): string | undefined {
if (!input) return undefined;
// clean any non alphanumeric characters from the input and replace with dashes
// the input cant be too long either, so limit to 50 characters
if (input.length > 50) {
input = input.substring(0, 50);
}
return input
.replace(/[^a-zA-Z0-9-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
export function validatePathRewriteConfig(
path: string | null,
pathMatchType: string | null,
rewritePath: string | null,
rewritePathType: string | null
): { isValid: boolean; error?: string } {
// If no path matching is configured, no rewriting is possible
if (!path || !pathMatchType) {
if (rewritePath || rewritePathType) {
return {
isValid: false,
error: "Path rewriting requires path matching to be configured"
};
}
return { isValid: true };
}
if (rewritePathType !== "stripPrefix") {
if ((rewritePath && !rewritePathType) || (!rewritePath && rewritePathType)) {
return { isValid: false, error: "Both rewritePath and rewritePathType must be specified together" };
}
}
if (!rewritePath || !rewritePathType) {
return { isValid: true };
}
const validPathMatchTypes = ["exact", "prefix", "regex"];
if (!validPathMatchTypes.includes(pathMatchType)) {
return {
isValid: false,
error: `Invalid pathMatchType: ${pathMatchType}. Must be one of: ${validPathMatchTypes.join(", ")}`
};
}
const validRewritePathTypes = ["exact", "prefix", "regex", "stripPrefix"];
if (!validRewritePathTypes.includes(rewritePathType)) {
return {
isValid: false,
error: `Invalid rewritePathType: ${rewritePathType}. Must be one of: ${validRewritePathTypes.join(", ")}`
};
}
if (pathMatchType === "regex") {
try {
new RegExp(path);
} catch (e) {
return {
isValid: false,
error: `Invalid regex pattern in path: ${path}`
};
}
}
// Additional validation for stripPrefix
if (rewritePathType === "stripPrefix") {
if (pathMatchType !== "prefix") {
logger.warn(`stripPrefix rewrite type is most effective with prefix path matching. Current match type: ${pathMatchType}`);
}
}
return { isValid: true };
}

View File

@@ -78,6 +78,13 @@ export function corsWithLoginPageSupport(corsConfig: any) {
return callback(null, true);
}
if (
corsConfig?.origins &&
corsConfig.origins.includes(origin)
) {
return callback(null, true);
}
// If origin doesn't match dashboard URL, check if it's a valid loginPage domain
const isValidDomain = await isValidLoginPageDomain(originHost);

View File

@@ -11,6 +11,7 @@ export enum OpenAPITags {
Invitation = "Invitation",
Target = "Target",
Rule = "Rule",
RuleTemplate = "Rule Template",
AccessToken = "Access Token",
Idp = "Identity Provider",
Client = "Client",

View File

@@ -15,6 +15,7 @@ import * as accessToken from "./accessToken";
import * as idp from "./idp";
import * as license from "./license";
import * as apiKeys from "./apiKeys";
import * as ruleTemplate from "./ruleTemplate";
import HttpCode from "@server/types/HttpCode";
import {
verifyAccessTokenAccess,
@@ -453,6 +454,80 @@ authenticated.delete(
resource.deleteResourceRule
);
// Rule template routes
authenticated.post(
"/org/:orgId/rule-templates",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createResourceRule),
ruleTemplate.createRuleTemplate
);
authenticated.get(
"/org/:orgId/rule-templates",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listResourceRules),
ruleTemplate.listRuleTemplates
);
authenticated.get(
"/org/:orgId/rule-templates/:templateId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listResourceRules),
ruleTemplate.getRuleTemplate
);
authenticated.put(
"/org/:orgId/rule-templates/:templateId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createResourceRule),
ruleTemplate.updateRuleTemplate
);
authenticated.get(
"/org/:orgId/rule-templates/:templateId/rules",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listResourceRules),
ruleTemplate.listTemplateRules
);
authenticated.post(
"/org/:orgId/rule-templates/:templateId/rules",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createResourceRule),
ruleTemplate.addTemplateRule
);
authenticated.put(
"/org/:orgId/rule-templates/:templateId/rules/:ruleId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createResourceRule),
ruleTemplate.updateTemplateRule
);
authenticated.delete(
"/org/:orgId/rule-templates/:templateId/rules/:ruleId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteResourceRule),
ruleTemplate.deleteTemplateRule
);
authenticated.delete(
"/org/:orgId/rule-templates/:templateId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteResourceRule),
ruleTemplate.deleteRuleTemplate
);
authenticated.put(
"/resource/:resourceId/templates/:templateId",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.createResourceRule),
ruleTemplate.assignTemplateToResource
);
authenticated.delete(
"/resource/:resourceId/templates/:templateId",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.deleteResourceRule),
ruleTemplate.unassignTemplateFromResource
);
authenticated.get(
"/resource/:resourceId/templates",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.listResourceRules),
ruleTemplate.listResourceTemplates
);
authenticated.get(
"/target/:targetId",
verifyTargetAccess,

View File

@@ -39,6 +39,7 @@ function queryResourceRules(resourceId: number) {
.select({
ruleId: resourceRules.ruleId,
resourceId: resourceRules.resourceId,
templateRuleId: resourceRules.templateRuleId,
action: resourceRules.action,
match: resourceRules.match,
value: resourceRules.value,

View File

@@ -0,0 +1,161 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { templateRules, ruleTemplates } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "@server/lib/validators";
const addTemplateRuleParamsSchema = z
.object({
orgId: z.string().min(1),
templateId: z.string().min(1)
})
.strict();
const addTemplateRuleBodySchema = z
.object({
action: z.enum(["ACCEPT", "DROP"]),
match: z.enum(["CIDR", "IP", "PATH"]),
value: z.string().min(1),
priority: z.number().int().optional(),
enabled: z.boolean().optional()
})
.strict();
export async function addTemplateRule(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = addTemplateRuleParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = addTemplateRuleBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId, templateId } = parsedParams.data;
const { action, match, value, priority, enabled = true } = parsedBody.data;
// Check if template exists and belongs to the organization
const existingTemplate = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.limit(1);
if (existingTemplate.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Rule template not found"
)
);
}
// Validate the value based on match type
if (match === "CIDR" && !isValidCIDR(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid CIDR format"
)
);
}
if (match === "IP" && !isValidIP(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid IP address format"
)
);
}
if (match === "PATH" && !isValidUrlGlobPattern(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid URL pattern format"
)
);
}
// Check for duplicate rule
const existingRule = await db
.select()
.from(templateRules)
.where(and(
eq(templateRules.templateId, templateId),
eq(templateRules.action, action),
eq(templateRules.match, match),
eq(templateRules.value, value)
))
.limit(1);
if (existingRule.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Rule already exists"
)
);
}
// Determine priority if not provided
let finalPriority = priority;
if (finalPriority === undefined) {
const maxPriority = await db
.select({ maxPriority: templateRules.priority })
.from(templateRules)
.where(eq(templateRules.templateId, templateId))
.orderBy(templateRules.priority)
.limit(1);
finalPriority = (maxPriority[0]?.maxPriority || 0) + 1;
}
// Add the rule
const [newRule] = await db
.insert(templateRules)
.values({
templateId,
action,
match,
value,
priority: finalPriority,
enabled
})
.returning();
return response(res, {
data: newRule,
success: true,
error: false,
message: "Template rule added successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,176 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourceTemplates, ruleTemplates, resources, templateRules, resourceRules } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const assignTemplateToResourceParamsSchema = z
.object({
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive()),
templateId: z.string().min(1)
})
.strict();
registry.registerPath({
method: "put",
path: "/resource/{resourceId}/templates/{templateId}",
description: "Assign a template to a resource.",
tags: [OpenAPITags.Resource, OpenAPITags.RuleTemplate],
request: {
params: assignTemplateToResourceParamsSchema
},
responses: {}
});
export async function assignTemplateToResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = assignTemplateToResourceParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId, templateId } = parsedParams.data;
// Verify that the referenced resource exists
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
// Verify that the template exists
const [template] = await db
.select()
.from(ruleTemplates)
.where(eq(ruleTemplates.templateId, templateId))
.limit(1);
if (!template) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Rule template with ID ${templateId} not found`
)
);
}
// Verify that the template belongs to the same organization as the resource
if (template.orgId !== resource.orgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
`Template ${templateId} does not belong to the same organization as resource ${resourceId}`
)
);
}
// Check if the template is already assigned to this resource
const [existingAssignment] = await db
.select()
.from(resourceTemplates)
.where(and(eq(resourceTemplates.resourceId, resourceId), eq(resourceTemplates.templateId, templateId)))
.limit(1);
if (existingAssignment) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Template ${templateId} is already assigned to resource ${resourceId}`
)
);
}
// Assign the template to the resource
await db
.insert(resourceTemplates)
.values({
resourceId,
templateId
});
// Automatically sync the template rules to the resource
try {
// Get all rules from the template
const templateRulesList = await db
.select()
.from(templateRules)
.where(eq(templateRules.templateId, templateId))
.orderBy(templateRules.priority);
if (templateRulesList.length > 0) {
// Get existing resource rules to calculate the next priority
const existingRules = await db
.select()
.from(resourceRules)
.where(eq(resourceRules.resourceId, resourceId))
.orderBy(resourceRules.priority);
// Calculate the starting priority for new template rules
// They should come after the highest existing priority
const maxExistingPriority = existingRules.length > 0
? Math.max(...existingRules.map(r => r.priority))
: 0;
// Create new resource rules from template rules with adjusted priorities
const newRules = templateRulesList.map((templateRule, index) => ({
resourceId,
templateRuleId: templateRule.ruleId, // Link to the template rule
action: templateRule.action,
match: templateRule.match,
value: templateRule.value,
priority: maxExistingPriority + index + 1, // Simple sequential ordering
enabled: templateRule.enabled
}));
await db
.insert(resourceRules)
.values(newRules);
}
} catch (error) {
logger.error("Error auto-syncing template rules during assignment:", error);
// Don't fail the assignment if sync fails, just log it
}
return response(res, {
data: { resourceId, templateId },
success: true,
error: false,
message: "Template assigned to resource successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,121 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { ruleTemplates } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { generateId } from "@server/auth/sessions/app";
const createRuleTemplateParamsSchema = z
.object({
orgId: z.string().min(1)
})
.strict();
const createRuleTemplateBodySchema = z
.object({
name: z.string().min(1).max(100).refine(name => name.trim().length > 0, {
message: "Template name cannot be empty or just whitespace"
}),
description: z.string().max(500).optional()
})
.strict();
registry.registerPath({
method: "post",
path: "/org/{orgId}/rule-templates",
description: "Create a rule template.",
tags: [OpenAPITags.Org, OpenAPITags.RuleTemplate],
request: {
params: createRuleTemplateParamsSchema,
body: {
content: {
"application/json": {
schema: createRuleTemplateBodySchema
}
}
}
},
responses: {}
});
export async function createRuleTemplate(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = createRuleTemplateParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = createRuleTemplateBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const { name, description } = parsedBody.data;
// Check if template with same name already exists
const existingTemplate = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.name, name)))
.limit(1);
if (existingTemplate.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`A template with the name "${name}" already exists in this organization`
)
);
}
const templateId = generateId(15);
const createdAt = Date.now();
const [newTemplate] = await db
.insert(ruleTemplates)
.values({
templateId,
orgId,
name,
description: description || null,
createdAt
})
.returning();
return response(res, {
data: newTemplate,
success: true,
error: false,
message: "Rule template created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,79 @@
import { z } from "zod";
import { db } from "@server/db";
import { ruleTemplates, templateRules, resourceTemplates } from "@server/db";
import { eq, and } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { generateId } from "@server/auth/sessions/app";
const deleteRuleTemplateSchema = z.object({
orgId: z.string().min(1),
templateId: z.string().min(1)
});
export async function deleteRuleTemplate(req: any, res: any) {
try {
const { orgId, templateId } = deleteRuleTemplateSchema.parse({
orgId: req.params.orgId,
templateId: req.params.templateId
});
// Check if template exists and belongs to the organization
const existingTemplate = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.limit(1);
if (existingTemplate.length === 0) {
return res.status(404).json({
success: false,
message: "Rule template not found"
});
}
// Get all template rules for this template
const templateRulesToDelete = await db
.select({ ruleId: templateRules.ruleId })
.from(templateRules)
.where(eq(templateRules.templateId, templateId));
// Delete resource rules that reference these template rules first
if (templateRulesToDelete.length > 0) {
const { resourceRules } = await import("@server/db");
const templateRuleIds = templateRulesToDelete.map(rule => rule.ruleId);
// Delete all resource rules that reference any of the template rules
for (const ruleId of templateRuleIds) {
await db
.delete(resourceRules)
.where(eq(resourceRules.templateRuleId, ruleId));
}
}
// Delete template rules
await db
.delete(templateRules)
.where(eq(templateRules.templateId, templateId));
// Delete resource template assignments
await db
.delete(resourceTemplates)
.where(eq(resourceTemplates.templateId, templateId));
// Delete the template
await db
.delete(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)));
return res.status(200).json({
success: true,
message: "Rule template deleted successfully"
});
} catch (error) {
console.error("Error deleting rule template:", error);
return res.status(500).json({
success: false,
message: "Internal server error"
});
}
}

View File

@@ -0,0 +1,114 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { templateRules, ruleTemplates } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
const deleteTemplateRuleParamsSchema = z
.object({
orgId: z.string().min(1),
templateId: z.string().min(1),
ruleId: z.string().min(1)
})
.strict();
export async function deleteTemplateRule(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = deleteTemplateRuleParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, templateId, ruleId } = parsedParams.data;
// Check if template exists and belongs to the organization
const existingTemplate = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.limit(1);
if (existingTemplate.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Rule template not found"
)
);
}
// Check if rule exists and belongs to the template
const existingRule = await db
.select()
.from(templateRules)
.where(and(eq(templateRules.templateId, templateId), eq(templateRules.ruleId, parseInt(ruleId))))
.limit(1);
if (existingRule.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Template rule not found"
)
);
}
// Count affected resources for the response message
let affectedResourcesCount = 0;
try {
const { resourceRules } = await import("@server/db");
// Get affected resource rules before deletion for counting
const affectedResourceRules = await db
.select()
.from(resourceRules)
.where(eq(resourceRules.templateRuleId, parseInt(ruleId)));
affectedResourcesCount = affectedResourceRules.length;
// Delete the resource rules first (due to foreign key constraint)
await db
.delete(resourceRules)
.where(eq(resourceRules.templateRuleId, parseInt(ruleId)));
} catch (error) {
logger.error("Error deleting resource rules created from template rule:", error);
// Don't fail the template rule deletion if resource rule deletion fails, just log it
}
// Delete the template rule after resource rules are deleted
await db
.delete(templateRules)
.where(and(eq(templateRules.templateId, templateId), eq(templateRules.ruleId, parseInt(ruleId))));
const message = affectedResourcesCount > 0
? `Template rule deleted successfully. Removed from ${affectedResourcesCount} assigned resource${affectedResourcesCount > 1 ? 's' : ''}.`
: "Template rule deleted successfully.";
return response(res, {
data: null,
success: true,
error: false,
message,
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,77 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { ruleTemplates } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
export type GetRuleTemplateResponse = {
templateId: string;
orgId: string;
name: string;
description: string | null;
createdAt: number;
};
const getRuleTemplateParamsSchema = z
.object({
orgId: z.string().min(1),
templateId: z.string().min(1)
})
.strict();
export async function getRuleTemplate(
req: any,
res: any,
next: any
): Promise<any> {
try {
const parsedParams = getRuleTemplateParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, templateId } = parsedParams.data;
// Get the template
const template = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.limit(1);
if (template.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Rule template not found"
)
);
}
return response(res, {
data: template[0],
success: true,
error: false,
message: "Rule template retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
console.error("Error getting rule template:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}

View File

@@ -0,0 +1,12 @@
export * from "./createRuleTemplate";
export * from "./listRuleTemplates";
export * from "./getRuleTemplate";
export * from "./updateRuleTemplate";
export * from "./listTemplateRules";
export * from "./addTemplateRule";
export * from "./updateTemplateRule";
export * from "./deleteTemplateRule";
export * from "./assignTemplateToResource";
export * from "./unassignTemplateFromResource";
export * from "./listResourceTemplates";
export * from "./deleteRuleTemplate";

View File

@@ -0,0 +1,104 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourceTemplates, ruleTemplates, resources } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const listResourceTemplatesParamsSchema = z
.object({
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
export type ListResourceTemplatesResponse = {
templates: Awaited<ReturnType<typeof queryResourceTemplates>>;
};
function queryResourceTemplates(resourceId: number) {
return db
.select({
templateId: ruleTemplates.templateId,
name: ruleTemplates.name,
description: ruleTemplates.description,
orgId: ruleTemplates.orgId,
createdAt: ruleTemplates.createdAt
})
.from(resourceTemplates)
.innerJoin(ruleTemplates, eq(resourceTemplates.templateId, ruleTemplates.templateId))
.where(eq(resourceTemplates.resourceId, resourceId))
.orderBy(ruleTemplates.createdAt);
}
registry.registerPath({
method: "get",
path: "/resource/{resourceId}/templates",
description: "List templates assigned to a resource.",
tags: [OpenAPITags.Resource, OpenAPITags.RuleTemplate],
request: {
params: listResourceTemplatesParamsSchema
},
responses: {}
});
export async function listResourceTemplates(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = listResourceTemplatesParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { resourceId } = parsedParams.data;
// Verify the resource exists
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
const templatesList = await queryResourceTemplates(resourceId);
return response<ListResourceTemplatesResponse>(res, {
data: {
templates: templatesList
},
success: true,
error: false,
message: "Resource templates retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,127 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { ruleTemplates } from "@server/db";
import { eq, sql } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const listRuleTemplatesParamsSchema = z
.object({
orgId: z.string().min(1)
})
.strict();
const listRuleTemplatesQuerySchema = z.object({
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().positive()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
});
export type ListRuleTemplatesResponse = {
templates: Awaited<ReturnType<typeof queryRuleTemplates>>;
pagination: { total: number; limit: number; offset: number };
};
function queryRuleTemplates(orgId: string) {
return db
.select({
templateId: ruleTemplates.templateId,
orgId: ruleTemplates.orgId,
name: ruleTemplates.name,
description: ruleTemplates.description,
createdAt: ruleTemplates.createdAt
})
.from(ruleTemplates)
.where(eq(ruleTemplates.orgId, orgId))
.orderBy(ruleTemplates.createdAt);
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/rule-templates",
description: "List rule templates for an organization.",
tags: [OpenAPITags.Org, OpenAPITags.RuleTemplate],
request: {
params: listRuleTemplatesParamsSchema,
query: listRuleTemplatesQuerySchema
},
responses: {}
});
export async function listRuleTemplates(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listRuleTemplatesQuerySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const { limit, offset } = parsedQuery.data;
const parsedParams = listRuleTemplatesParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
const baseQuery = queryRuleTemplates(orgId);
let templatesList = await baseQuery.limit(limit).offset(offset);
// Get total count
const countResult = await db
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(ruleTemplates)
.where(eq(ruleTemplates.orgId, orgId));
const totalCount = Number(countResult[0]?.count || 0);
return response<ListRuleTemplatesResponse>(res, {
data: {
templates: templatesList,
pagination: {
total: totalCount,
limit,
offset
}
},
success: true,
error: false,
message: "Rule templates retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,73 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { templateRules, ruleTemplates } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
const listTemplateRulesParamsSchema = z
.object({
orgId: z.string().min(1),
templateId: z.string().min(1)
})
.strict();
export async function listTemplateRules(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = listTemplateRulesParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, templateId } = parsedParams.data;
// Check if template exists and belongs to the organization
const existingTemplate = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.limit(1);
if (existingTemplate.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Rule template not found"
)
);
}
// Get template rules
const rules = await db
.select()
.from(templateRules)
.where(eq(templateRules.templateId, templateId))
.orderBy(templateRules.priority);
return response(res, {
data: { rules },
success: true,
error: false,
message: "Template rules retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,130 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourceTemplates, resources, resourceRules, templateRules } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const unassignTemplateFromResourceParamsSchema = z
.object({
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive()),
templateId: z.string().min(1)
})
.strict();
registry.registerPath({
method: "delete",
path: "/resource/{resourceId}/templates/{templateId}",
description: "Unassign a template from a resource.",
tags: [OpenAPITags.Resource, OpenAPITags.RuleTemplate],
request: {
params: unassignTemplateFromResourceParamsSchema
},
responses: {}
});
export async function unassignTemplateFromResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = unassignTemplateFromResourceParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId, templateId } = parsedParams.data;
// Verify that the referenced resource exists
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
// Check if the template is assigned to this resource
const [existingAssignment] = await db
.select()
.from(resourceTemplates)
.where(and(eq(resourceTemplates.resourceId, resourceId), eq(resourceTemplates.templateId, templateId)))
.limit(1);
if (!existingAssignment) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Template ${templateId} is not assigned to resource ${resourceId}`
)
);
}
// Remove the template assignment
await db
.delete(resourceTemplates)
.where(and(eq(resourceTemplates.resourceId, resourceId), eq(resourceTemplates.templateId, templateId)));
// Remove all resource rules that were created from this template
// We can now use the templateRuleId to precisely identify which rules to remove
try {
// Get all template rules for this template
const templateRulesList = await db
.select()
.from(templateRules)
.where(eq(templateRules.templateId, templateId))
.orderBy(templateRules.priority);
if (templateRulesList.length > 0) {
// Remove resource rules that have templateRuleId matching any of the template rules
for (const templateRule of templateRulesList) {
await db
.delete(resourceRules)
.where(and(
eq(resourceRules.resourceId, resourceId),
eq(resourceRules.templateRuleId, templateRule.ruleId)
));
}
}
} catch (error) {
logger.error("Error removing template rules during unassignment:", error);
// Don't fail the unassignment if rule removal fails, just log it
}
return response(res, {
data: { resourceId, templateId },
success: true,
error: false,
message: "Template unassigned from resource successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,117 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { ruleTemplates } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
const updateRuleTemplateParamsSchema = z
.object({
orgId: z.string().min(1),
templateId: z.string().min(1)
})
.strict();
const updateRuleTemplateBodySchema = z
.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional()
})
.strict();
export async function updateRuleTemplate(
req: any,
res: any,
next: any
): Promise<any> {
try {
const parsedParams = updateRuleTemplateParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = updateRuleTemplateBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId, templateId } = parsedParams.data;
const { name, description } = parsedBody.data;
// Check if template exists and belongs to the organization
const existingTemplate = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.limit(1);
if (existingTemplate.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Rule template not found"
)
);
}
// Check if another template with the same name already exists (excluding current template)
const duplicateTemplate = await db
.select()
.from(ruleTemplates)
.where(and(
eq(ruleTemplates.orgId, orgId),
eq(ruleTemplates.name, name),
eq(ruleTemplates.templateId, templateId)
))
.limit(1);
if (duplicateTemplate.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Template with name "${name}" already exists`
)
);
}
// Update the template
const [updatedTemplate] = await db
.update(ruleTemplates)
.set({
name,
description: description || null
})
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.returning();
return response(res, {
data: updatedTemplate,
success: true,
error: false,
message: "Rule template updated successfully",
status: HttpCode.OK
});
} catch (error) {
console.error("Error updating rule template:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}

View File

@@ -0,0 +1,194 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { templateRules, ruleTemplates } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "@server/lib/validators";
const updateTemplateRuleParamsSchema = z
.object({
orgId: z.string().min(1),
templateId: z.string().min(1),
ruleId: z.string().min(1)
})
.strict();
const updateTemplateRuleBodySchema = z
.object({
action: z.enum(["ACCEPT", "DROP"]).optional(),
match: z.enum(["CIDR", "IP", "PATH"]).optional(),
value: z.string().min(1).optional(),
priority: z.number().int().optional(),
enabled: z.boolean().optional()
})
.strict();
export async function updateTemplateRule(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = updateTemplateRuleParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = updateTemplateRuleBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId, templateId, ruleId } = parsedParams.data;
const updateData = parsedBody.data;
// Check if template exists and belongs to the organization
const existingTemplate = await db
.select()
.from(ruleTemplates)
.where(and(eq(ruleTemplates.orgId, orgId), eq(ruleTemplates.templateId, templateId)))
.limit(1);
if (existingTemplate.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Rule template not found"
)
);
}
// Check if rule exists and belongs to the template
const existingRule = await db
.select()
.from(templateRules)
.where(and(eq(templateRules.templateId, templateId), eq(templateRules.ruleId, parseInt(ruleId))))
.limit(1);
if (existingRule.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Template rule not found"
)
);
}
// Validate the value if it's being updated
if (updateData.value && updateData.match) {
if (updateData.match === "CIDR" && !isValidCIDR(updateData.value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid CIDR format"
)
);
}
if (updateData.match === "IP" && !isValidIP(updateData.value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid IP address format"
)
);
}
if (updateData.match === "PATH" && !isValidUrlGlobPattern(updateData.value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid URL pattern format"
)
);
}
}
// Update the rule
const [updatedRule] = await db
.update(templateRules)
.set(updateData)
.where(and(eq(templateRules.templateId, templateId), eq(templateRules.ruleId, parseInt(ruleId))))
.returning();
// Propagate changes to all resource rules created from this template rule
try {
const { resourceRules } = await import("@server/db");
// Find all resource rules that were created from this template rule
const affectedResourceRules = await db
.select()
.from(resourceRules)
.where(eq(resourceRules.templateRuleId, parseInt(ruleId)));
if (affectedResourceRules.length > 0) {
// Update all affected resource rules with the same changes
// Note: We don't update priority as that should remain independent
const propagationData = {
...updateData,
priority: undefined // Don't propagate priority changes
};
// Remove undefined values
Object.keys(propagationData).forEach(key => {
if ((propagationData as any)[key] === undefined) {
delete (propagationData as any)[key];
}
});
if (Object.keys(propagationData).length > 0) {
await db
.update(resourceRules)
.set(propagationData)
.where(eq(resourceRules.templateRuleId, parseInt(ruleId)));
}
}
} catch (error) {
logger.error("Error propagating template rule changes to resource rules:", error);
// Don't fail the template rule update if propagation fails, just log it
}
// Count affected resources for the response message
let affectedResourcesCount = 0;
try {
const { resourceTemplates } = await import("@server/db");
const affectedResources = await db
.select()
.from(resourceTemplates)
.where(eq(resourceTemplates.templateId, templateId));
affectedResourcesCount = affectedResources.length;
} catch (error) {
logger.error("Error counting affected resources:", error);
}
const message = affectedResourcesCount > 0
? `Template rule updated successfully. Changes propagated to ${affectedResourcesCount} assigned resource${affectedResourcesCount > 1 ? 's' : ''}.`
: "Template rule updated successfully.";
return response(res, {
data: updatedRule,
success: true,
error: false,
message,
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -53,7 +53,8 @@ const createTargetSchema = z
path: z.string().optional().nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
rewritePath: z.string().optional().nullable(),
rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable()
rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable(),
priority: z.number().int().min(1).max(1000)
})
.strict();
@@ -210,7 +211,10 @@ export async function createTarget(
internalPort,
enabled: targetData.enabled,
path: targetData.path,
pathMatchType: targetData.pathMatchType
pathMatchType: targetData.pathMatchType,
rewritePath: targetData.rewritePath,
rewritePathType: targetData.rewritePathType,
priority: targetData.priority
})
.returning();

View File

@@ -62,7 +62,8 @@ function queryTargets(resourceId: number) {
path: targets.path,
pathMatchType: targets.pathMatchType,
rewritePath: targets.rewritePath,
rewritePathType: targets.rewritePathType
rewritePathType: targets.rewritePathType,
priority: targets.priority,
})
.from(targets)
.leftJoin(sites, eq(sites.siteId, targets.siteId))

View File

@@ -50,7 +50,8 @@ const updateTargetBodySchema = z
path: z.string().optional().nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
rewritePath: z.string().optional().nullable(),
rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable()
rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable(),
priority: z.number().int().min(1).max(1000).optional(),
})
.strict()
.refine((data) => Object.keys(data).length > 0, {
@@ -198,7 +199,10 @@ export async function updateTarget(
internalPort,
enabled: parsedBody.data.enabled,
path: parsedBody.data.path,
pathMatchType: parsedBody.data.pathMatchType
pathMatchType: parsedBody.data.pathMatchType,
priority: parsedBody.data.priority,
rewritePath: parsedBody.data.rewritePath,
rewritePathType: parsedBody.data.rewritePathType
})
.where(eq(targets.targetId, targetId))
.returning();

View File

@@ -0,0 +1,63 @@
import { db } from "@server/db/pg";
import { ruleTemplates, templateRules, resourceTemplates } from "@server/db/pg/schema";
const version = "1.10.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
// Create rule templates table
await db.execute(`
CREATE TABLE IF NOT EXISTS "ruleTemplates" (
"templateId" varchar PRIMARY KEY,
"orgId" varchar NOT NULL,
"name" varchar NOT NULL,
"description" varchar,
"createdAt" bigint NOT NULL,
FOREIGN KEY ("orgId") REFERENCES "orgs" ("orgId") ON DELETE CASCADE
);
`);
// Create template rules table
await db.execute(`
CREATE TABLE IF NOT EXISTS "templateRules" (
"ruleId" serial PRIMARY KEY,
"templateId" varchar NOT NULL,
"enabled" boolean NOT NULL DEFAULT true,
"priority" integer NOT NULL,
"action" varchar NOT NULL,
"match" varchar NOT NULL,
"value" varchar NOT NULL,
FOREIGN KEY ("templateId") REFERENCES "ruleTemplates" ("templateId") ON DELETE CASCADE
);
`);
// Create resource templates table
await db.execute(`
CREATE TABLE IF NOT EXISTS "resourceTemplates" (
"resourceId" integer NOT NULL,
"templateId" varchar NOT NULL,
PRIMARY KEY ("resourceId", "templateId"),
FOREIGN KEY ("resourceId") REFERENCES "resources" ("resourceId") ON DELETE CASCADE,
FOREIGN KEY ("templateId") REFERENCES "ruleTemplates" ("templateId") ON DELETE CASCADE
);
`);
console.log("Added rule template tables");
// Add templateRuleId column to resourceRules table
await db.execute(`
ALTER TABLE "resourceRules"
ADD COLUMN "templateRuleId" INTEGER
REFERENCES "templateRules"("ruleId") ON DELETE CASCADE
`);
console.log("Added templateRuleId column to resourceRules table");
} catch (e) {
console.log("Unable to add rule template tables and columns");
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -0,0 +1,70 @@
import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
import { db } from "@server/db/sqlite";
const version = "1.10.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const sqliteDb = new Database(location);
try {
sqliteDb.transaction(() => {
// Create rule templates table
sqliteDb.exec(`
CREATE TABLE IF NOT EXISTS 'ruleTemplates' (
'templateId' text PRIMARY KEY,
'orgId' text NOT NULL,
'name' text NOT NULL,
'description' text,
'createdAt' integer NOT NULL,
FOREIGN KEY ('orgId') REFERENCES 'orgs' ('orgId') ON DELETE CASCADE
);
`);
// Create template rules table
sqliteDb.exec(`
CREATE TABLE IF NOT EXISTS 'templateRules' (
'ruleId' integer PRIMARY KEY AUTOINCREMENT,
'templateId' text NOT NULL,
'enabled' integer NOT NULL DEFAULT 1,
'priority' integer NOT NULL,
'action' text NOT NULL,
'match' text NOT NULL,
'value' text NOT NULL,
FOREIGN KEY ('templateId') REFERENCES 'ruleTemplates' ('templateId') ON DELETE CASCADE
);
`);
// Create resource templates table
sqliteDb.exec(`
CREATE TABLE IF NOT EXISTS 'resourceTemplates' (
'resourceId' integer NOT NULL,
'templateId' text NOT NULL,
PRIMARY KEY ('resourceId', 'templateId'),
FOREIGN KEY ('resourceId') REFERENCES 'resources' ('resourceId') ON DELETE CASCADE,
FOREIGN KEY ('templateId') REFERENCES 'ruleTemplates' ('templateId') ON DELETE CASCADE
);
`);
})();
console.log("Added rule template tables");
// Add templateRuleId column to resourceRules table
await db.run(`
ALTER TABLE resourceRules
ADD COLUMN templateRuleId INTEGER
REFERENCES templateRules(ruleId) ON DELETE CASCADE
`);
console.log("Added templateRuleId column to resourceRules table");
} catch (e) {
console.log("Unable to add rule template tables and columns");
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -62,7 +62,6 @@ function parseSetCookieString(
: new URL(env.app.dashboardUrl).hostname;
if (d) {
options.domain = d;
console.log("Setting cookie domain to:", d);
}
}

View File

@@ -74,7 +74,10 @@ import {
CircleX,
ArrowRight,
Plus,
MoveRight
MoveRight,
ArrowUp,
Info,
ArrowDown
} from "lucide-react";
import { ContainersSelector } from "@app/components/ContainersSelector";
import { useTranslations } from "next-intl";
@@ -106,6 +109,7 @@ import {
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import { Badge } from "@app/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip";
const addTargetSchema = z
.object({
@@ -122,7 +126,8 @@ const addTargetSchema = z
rewritePathType: z
.enum(["exact", "prefix", "regex", "stripPrefix"])
.optional()
.nullable()
.nullable(),
priority: z.number().int().min(1).max(1000)
})
.refine(
(data) => {
@@ -301,7 +306,8 @@ export default function ReverseProxyTargets(props: {
path: null,
pathMatchType: null,
rewritePath: null,
rewritePathType: null
rewritePathType: null,
priority: 100
} as z.infer<typeof addTargetSchema>
});
@@ -485,6 +491,7 @@ export default function ReverseProxyTargets(props: {
targetId: new Date().getTime(),
new: true,
resourceId: resource.resourceId,
priority: 100,
hcEnabled: false,
hcPath: null,
hcMethod: null,
@@ -509,7 +516,8 @@ export default function ReverseProxyTargets(props: {
path: null,
pathMatchType: null,
rewritePath: null,
rewritePathType: null
rewritePathType: null,
priority: 100,
});
}
@@ -587,7 +595,8 @@ export default function ReverseProxyTargets(props: {
path: target.path,
pathMatchType: target.pathMatchType,
rewritePath: target.rewritePath,
rewritePathType: target.rewritePathType
rewritePathType: target.rewritePathType,
priority: target.priority
};
if (target.new) {
@@ -660,6 +669,46 @@ export default function ReverseProxyTargets(props: {
}
const columns: ColumnDef<LocalTarget>[] = [
{
id: "priority",
header: () => (
<div className="flex items-center gap-2">
Priority
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Info className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
),
cell: ({ row }) => {
return (
<div className="flex items-center gap-2">
<Input
type="number"
min="1"
max="1000"
defaultValue={row.original.priority || 100}
className="w-20"
onBlur={(e) => {
const value = parseInt(e.target.value, 10);
if (value >= 1 && value <= 1000) {
updateTarget(row.original.targetId, {
...row.original,
priority: value
});
}
}}
/>
</div>
);
}
},
{
accessorKey: "path",
header: t("matchPath"),

View File

@@ -58,7 +58,7 @@ import {
} from "@app/components/ui/popover";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { cn } from "@app/lib/cn";
import { ArrowRight, MoveRight, Plus, SquareArrowOutUpRight } from "lucide-react";
import { ArrowRight, Info, MoveRight, Plus, SquareArrowOutUpRight } from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox";
import Link from "next/link";
import { useTranslations } from "next-intl";
@@ -92,6 +92,7 @@ import { parseHostTarget } from "@app/lib/parseHostTarget";
import { toASCII, toUnicode } from 'punycode';
import { DomainRow } from "../../../../../components/DomainsTable";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip";
import { PathMatchDisplay, PathMatchModal, PathRewriteDisplay, PathRewriteModal } from "@app/components/PathMatchRenameModal";
@@ -119,7 +120,8 @@ const addTargetSchema = z.object({
path: z.string().optional().nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
rewritePath: z.string().optional().nullable(),
rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable()
rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable(),
priority: z.number().int().min(1).max(1000)
}).refine(
(data) => {
// If path is provided, pathMatchType must be provided
@@ -262,6 +264,7 @@ export default function Page() {
pathMatchType: null,
rewritePath: null,
rewritePathType: null,
priority: 100,
} as z.infer<typeof addTargetSchema>
});
@@ -341,6 +344,7 @@ export default function Page() {
targetId: new Date().getTime(),
new: true,
resourceId: 0, // Will be set when resource is created
priority: 100, // Default priority
hcEnabled: false,
hcPath: null,
hcMethod: null,
@@ -366,6 +370,7 @@ export default function Page() {
pathMatchType: null,
rewritePath: null,
rewritePathType: null,
priority: 100,
});
}
@@ -475,7 +480,8 @@ export default function Page() {
path: target.path,
pathMatchType: target.pathMatchType,
rewritePath: target.rewritePath,
rewritePathType: target.rewritePathType
rewritePathType: target.rewritePathType,
priority: target.priority
};
await api.put(`/resource/${id}/target`, data);
@@ -598,6 +604,46 @@ export default function Page() {
}, []);
const columns: ColumnDef<LocalTarget>[] = [
{
id: "priority",
header: () => (
<div className="flex items-center gap-2">
Priority
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Info className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
),
cell: ({ row }) => {
return (
<div className="flex items-center gap-2">
<Input
type="number"
min="1"
max="1000"
defaultValue={row.original.priority || 100}
className="w-20"
onBlur={(e) => {
const value = parseInt(e.target.value, 10);
if (value >= 1 && value <= 1000) {
updateTarget(row.original.targetId, {
...row.original,
priority: value
});
}
}}
/>
</div>
);
}
},
{
accessorKey: "path",
header: t("matchPath"),

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,166 @@
"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,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionFooter,
SettingsSectionForm
} 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: "Template Updated",
description: "Template details have been updated successfully. Changes to template rules will automatically propagate to all assigned resources.",
variant: "default"
});
} 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>
{t("templateDetails")}
</SettingsSectionTitle>
<SettingsSectionDescription>
Update the name and description for this rule template.
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" id="template-general-form">
<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>
</form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button type="submit" form="template-general-form" disabled={saving}>
<Save className="w-4 h-4 mr-2" />
{saving ? t("saving") : t("save")}
</Button>
</SettingsSectionFooter>
</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,39 @@
"use client";
import { useParams } from "next/navigation";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody
} from "@app/components/Settings";
import { TemplateRulesManager } from "@app/components/ruleTemplate/TemplateRulesManager";
import { useTranslations } from "next-intl";
export default function RulesPage() {
const params = useParams();
const t = useTranslations();
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('ruleTemplates')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Manage the rules for this template. Changes propagate to all assigned resources.
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<TemplateRulesManager
orgId={params.orgId as string}
templateId={params.templateId as string}
/>
</SettingsSectionBody>
</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

@@ -15,7 +15,8 @@ import {
Globe, // Added from 'dev' branch
MonitorUp, // Added from 'dev' branch
Server,
Zap
Zap,
Shield
} from "lucide-react";
export type SidebarNavSection = {
@@ -105,6 +106,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

@@ -0,0 +1,78 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@app/components/ui/dialog";
import { Button } from "@app/components/ui/button";
import { AlertTriangle } from "lucide-react";
interface ConfirmationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmText?: string;
cancelText?: string;
variant?: "destructive" | "default";
onConfirm: () => Promise<void> | void;
loading?: boolean;
}
export function ConfirmationDialog({
open,
onOpenChange,
title,
description,
confirmText = "Confirm",
cancelText = "Cancel",
variant = "destructive",
onConfirm,
loading = false
}: ConfirmationDialogProps) {
const handleConfirm = async () => {
try {
await onConfirm();
onOpenChange(false);
} catch (error) {
// Error handling is done by the calling component
console.error("Confirmation action failed:", error);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
{title}
</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
{cancelText}
</Button>
<Button
variant={variant}
onClick={handleConfirm}
disabled={loading}
>
{loading ? "Processing..." : confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

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,224 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
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";
import { ConfirmationDialog } from "@app/components/ConfirmationDialog";
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 [unassignDialogOpen, setUnassignDialogOpen] = useState(false);
const [templateToUnassign, setTemplateToUnassign] = useState<string | null>(null);
const [unassigning, setUnassigning] = useState(false);
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: "Template Assigned",
description: "Template has been assigned to this resource. All template rules have been applied and will be automatically updated when the template changes.",
variant: "default"
});
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) => {
setTemplateToUnassign(templateId);
setUnassignDialogOpen(true);
};
const confirmUnassignTemplate = async () => {
if (!templateToUnassign) return;
setUnassigning(true);
try {
const response = await api.delete(`/resource/${resourceId}/templates/${templateToUnassign}`);
if (response.status === 200 || response.status === 201) {
toast({
title: "Template Unassigned",
description: "Template has been unassigned from this resource. All template-managed rules have been removed from this resource.",
variant: "default"
});
await fetchData();
if (onUpdate) {
await onUpdate();
}
} else {
toast({
title: "Unassign Failed",
description: response.data.message || "Failed to unassign template. Please try again.",
variant: "destructive"
});
}
} catch (error) {
toast({
title: "Unassign Failed",
description: formatAxiosError(error, "Failed to unassign template. Please try again."),
variant: "destructive"
});
} finally {
setUnassigning(false);
setTemplateToUnassign(null);
}
};
if (loading) {
return <div>Loading...</div>;
}
return (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-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>
<div className="space-y-2">
{resourceTemplates.map((template) => (
<div
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>
{template.description && (
<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>
</div>
)}
</div>
<ConfirmationDialog
open={unassignDialogOpen}
onOpenChange={setUnassignDialogOpen}
title="Unassign Template"
description="Are you sure you want to unassign this template? This will remove all template-managed rules from this resource. This action cannot be undone."
confirmText="Unassign Template"
cancelText="Cancel"
variant="destructive"
onConfirm={confirmUnassignTemplate}
loading={unassigning}
/>
</div>
);
}

View File

@@ -0,0 +1,626 @@
"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, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { ConfirmationDialog } from "@app/components/ConfirmationDialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@app/components/ui/dialog";
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 [createDialogOpen, setCreateDialogOpen] = useState(false);
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 25
});
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [ruleToDelete, setRuleToDelete] = useState<number | null>(null);
const [deletingRule, setDeletingRule] = useState(false);
const RuleAction = {
ACCEPT: t('alwaysAllow'),
DROP: t('alwaysDeny')
} as const;
const RuleMatch = {
PATH: t('path'),
IP: "IP",
CIDR: t('ipAddressRange')
} as const;
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;
}
const response = await api.post(`/org/${orgId}/rule-templates/${templateId}/rules`, data);
toast({
title: "Template Rule Added",
description: "A new rule has been added to the template. It will be available for assignment to resources.",
variant: "default"
});
form.reset();
fetchRules();
} catch (error) {
toast({
variant: "destructive",
title: "Add Rule Failed",
description: formatAxiosError(error, "Failed to add rule. Please check your input and try again.")
});
} finally {
setAddingRule(false);
}
};
const removeRule = async (ruleId: number) => {
setRuleToDelete(ruleId);
setDeleteDialogOpen(true);
};
const confirmDeleteRule = async () => {
if (!ruleToDelete) return;
setDeletingRule(true);
try {
await api.delete(`/org/${orgId}/rule-templates/${templateId}/rules/${ruleToDelete}`);
toast({
title: "Template Rule Removed",
description: "The rule has been removed from the template and from all assigned resources.",
variant: "default"
});
fetchRules();
} catch (error) {
toast({
variant: "destructive",
title: "Removal Failed",
description: formatAxiosError(error, "Failed to remove template rule")
});
} finally {
setDeletingRule(false);
setRuleToDelete(null);
}
};
const updateRule = async (ruleId: number, data: Partial<TemplateRule>) => {
try {
const response = await api.put(`/org/${orgId}/rule-templates/${templateId}/rules/${ruleId}`, data);
// Show success notification with propagation info if available
const message = response.data?.message || "The template rule has been updated and changes have been propagated to all assigned resources.";
toast({
title: "Template Rule Updated",
description: message,
variant: "default"
});
fetchRules();
} catch (error) {
console.error("Failed to update rule:", error);
toast({
title: "Update Failed",
description: formatAxiosError(error, "Failed to update template rule. Please try again."),
variant: "destructive"
});
}
};
const columns: ColumnDef<TemplateRule>[] = [
{
accessorKey: "priority",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('rulesPriority')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => (
<Input
defaultValue={row.original.priority}
className="w-[75px]"
type="number"
onBlur={(e) => {
const parsed = z.coerce
.number()
.int()
.optional()
.safeParse(e.target.value);
if (!parsed.data) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidIpAddress'),
description: t('rulesErrorInvalidPriorityDescription')
});
return;
}
updateRule(row.original.ruleId, {
priority: parsed.data
});
}}
/>
)
},
{
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>
)
},
{
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>
)
},
{
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 })
}
/>
)
},
{
accessorKey: "enabled",
header: t('enabled'),
cell: ({ row }) => (
<Switch
defaultChecked={row.original.enabled}
onCheckedChange={(val) =>
updateRule(row.original.ruleId, { enabled: val })
}
/>
)
},
{
id: "actions",
cell: ({ row }) => (
<Button
variant="outline"
onClick={() => removeRule(row.original.ruleId)}
>
{t('delete')}
</Button>
)
}
];
const table = useReactTable({
data: rules,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
state: {
pagination
},
onPaginationChange: setPagination,
manualPagination: false
});
if (loading) {
return <div className="text-muted-foreground">Loading...</div>;
}
return (
<div className="space-y-6">
<div className="flex justify-end">
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogTrigger asChild>
<Button variant="secondary" disabled={addingRule}>
{addingRule ? "Adding Rule..." : t('ruleSubmit')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('ruleSubmit')}</DialogTitle>
<DialogDescription>
{t('rulesResourceDescription')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.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={form.control}
name="action"
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>
<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"
>
No rules found. Add your first rule above.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* 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>
)}
{/* Confirmation Dialog */}
<ConfirmationDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title="Delete Template Rule"
description="Are you sure you want to delete this rule? This action will remove the rule from the template and from all assigned resources. This action cannot be undone."
confirmText="Delete Rule"
cancelText="Cancel"
variant="destructive"
onConfirm={confirmDeleteRule}
loading={deletingRule}
/>
</div>
);
}