mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-24 13:56:39 +00:00
Compare commits
30 Commits
1.11.0-s.2
...
policies
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fa44981cf | ||
|
|
1bacad7854 | ||
|
|
b627e391ac | ||
|
|
40a3eac704 | ||
|
|
2d30b155f2 | ||
|
|
1333e21553 | ||
|
|
4c412528f5 | ||
|
|
a8fce47ba0 | ||
|
|
4447fb8202 | ||
|
|
1c9c4b1802 | ||
|
|
19e15f4ef5 | ||
|
|
c2c29e2cd2 | ||
|
|
7b33dc591d | ||
|
|
a95f2e76f4 | ||
|
|
979860a951 | ||
|
|
9e9a81d9e8 | ||
|
|
8f09561114 | ||
|
|
e4c0a157e3 | ||
|
|
22477b7e81 | ||
|
|
b6c76a2164 | ||
|
|
043834274d | ||
|
|
1e4ca69c89 | ||
|
|
ff2bcfb0e7 | ||
|
|
b47fc9f901 | ||
|
|
ee8952de10 | ||
|
|
75cec731e8 | ||
|
|
16a88281bb | ||
|
|
1574cbc5df | ||
|
|
2cb2a115b0 | ||
|
|
9dce7b2cde |
2
.github/workflows/cicd.yml
vendored
2
.github/workflows/cicd.yml
vendored
@@ -3,7 +3,7 @@ name: CI/CD Pipeline
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "*"
|
- "[0-9]+.[0-9]+.[0-9]+"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
|
|||||||
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
@@ -35,6 +35,9 @@ jobs:
|
|||||||
- name: Apply database migrations
|
- name: Apply database migrations
|
||||||
run: npm run db:sqlite:push
|
run: npm run db:sqlite:push
|
||||||
|
|
||||||
|
- name: Test with tsc
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
- name: Start app in background
|
- name: Start app in background
|
||||||
run: nohup npm run dev &
|
run: nohup npm run dev &
|
||||||
|
|
||||||
|
|||||||
@@ -1333,7 +1333,7 @@
|
|||||||
"twoFactorRequired": "Двуфакторното удостоверяване е необходимо за регистрация на ключ за защита.",
|
"twoFactorRequired": "Двуфакторното удостоверяване е необходимо за регистрация на ключ за защита.",
|
||||||
"twoFactor": "Двуфакторно удостоверяване",
|
"twoFactor": "Двуфакторно удостоверяване",
|
||||||
"adminEnabled2FaOnYourAccount": "Вашият администратор е активирал двуфакторно удостоверяване за {email}. Моля, завършете процеса по настройка, за да продължите.",
|
"adminEnabled2FaOnYourAccount": "Вашият администратор е активирал двуфакторно удостоверяване за {email}. Моля, завършете процеса по настройка, за да продължите.",
|
||||||
"continueToApplication": "Продължаване към приложението",
|
"continueToApplication": "Продължете към приложението",
|
||||||
"securityKeyAdd": "Добавяне на ключ за сигурност",
|
"securityKeyAdd": "Добавяне на ключ за сигурност",
|
||||||
"securityKeyRegisterTitle": "Регистриране на нов ключ за сигурност",
|
"securityKeyRegisterTitle": "Регистриране на нов ключ за сигурност",
|
||||||
"securityKeyRegisterDescription": "Свържете ключа за сигурност и въведете име, по което да го идентифицирате",
|
"securityKeyRegisterDescription": "Свържете ключа за сигурност и въведете име, по което да го идентифицирате",
|
||||||
|
|||||||
@@ -1333,7 +1333,7 @@
|
|||||||
"twoFactorRequired": "Pro registraci bezpečnostního klíče je nutné dvoufaktorové ověření.",
|
"twoFactorRequired": "Pro registraci bezpečnostního klíče je nutné dvoufaktorové ověření.",
|
||||||
"twoFactor": "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í.",
|
"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íč",
|
"securityKeyAdd": "Přidat bezpečnostní klíč",
|
||||||
"securityKeyRegisterTitle": "Registrovat nový bezpečnostní klíč",
|
"securityKeyRegisterTitle": "Registrovat nový bezpečnostní klíč",
|
||||||
"securityKeyRegisterDescription": "Připojte svůj bezpečnostní klíč a zadejte jméno pro jeho identifikaci",
|
"securityKeyRegisterDescription": "Připojte svůj bezpečnostní klíč a zadejte jméno pro jeho identifikaci",
|
||||||
|
|||||||
@@ -1041,6 +1041,26 @@
|
|||||||
"actionDeleteResourceRule": "Delete Resource Rule",
|
"actionDeleteResourceRule": "Delete Resource Rule",
|
||||||
"actionListResourceRules": "List Resource Rules",
|
"actionListResourceRules": "List Resource Rules",
|
||||||
"actionUpdateResourceRule": "Update Resource Rule",
|
"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",
|
"actionListOrgs": "List Organizations",
|
||||||
"actionCheckOrgId": "Check ID",
|
"actionCheckOrgId": "Check ID",
|
||||||
"actionCreateOrg": "Create Organization",
|
"actionCreateOrg": "Create Organization",
|
||||||
@@ -1136,6 +1156,7 @@
|
|||||||
"sidebarInvitations": "Invitations",
|
"sidebarInvitations": "Invitations",
|
||||||
"sidebarRoles": "Roles",
|
"sidebarRoles": "Roles",
|
||||||
"sidebarShareableLinks": "Shareable Links",
|
"sidebarShareableLinks": "Shareable Links",
|
||||||
|
"sidebarRuleTemplates": "Rule Templates",
|
||||||
"sidebarApiKeys": "API Keys",
|
"sidebarApiKeys": "API Keys",
|
||||||
"sidebarSettings": "Settings",
|
"sidebarSettings": "Settings",
|
||||||
"sidebarAllUsers": "All Users",
|
"sidebarAllUsers": "All Users",
|
||||||
|
|||||||
@@ -1333,7 +1333,7 @@
|
|||||||
"twoFactorRequired": "È richiesta l'autenticazione a due fattori per registrare una chiave di sicurezza.",
|
"twoFactorRequired": "È richiesta l'autenticazione a due fattori per registrare una chiave di sicurezza.",
|
||||||
"twoFactor": "Autenticazione a Due Fattori",
|
"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.",
|
"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",
|
"securityKeyAdd": "Aggiungi Chiave di Sicurezza",
|
||||||
"securityKeyRegisterTitle": "Registra Nuova Chiave di Sicurezza",
|
"securityKeyRegisterTitle": "Registra Nuova Chiave di Sicurezza",
|
||||||
"securityKeyRegisterDescription": "Collega la tua chiave di sicurezza e inserisci un nome per identificarla",
|
"securityKeyRegisterDescription": "Collega la tua chiave di sicurezza e inserisci un nome per identificarla",
|
||||||
|
|||||||
@@ -1333,7 +1333,7 @@
|
|||||||
"twoFactorRequired": "Tweestapsverificatie is vereist om een beveiligingssleutel te registreren.",
|
"twoFactorRequired": "Tweestapsverificatie is vereist om een beveiligingssleutel te registreren.",
|
||||||
"twoFactor": "Tweestapsverificatie",
|
"twoFactor": "Tweestapsverificatie",
|
||||||
"adminEnabled2FaOnYourAccount": "Je beheerder heeft tweestapsverificatie voor {email} ingeschakeld. Voltooi het instellingsproces om verder te gaan.",
|
"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",
|
"securityKeyAdd": "Beveiligingssleutel toevoegen",
|
||||||
"securityKeyRegisterTitle": "Nieuwe beveiligingssleutel registreren",
|
"securityKeyRegisterTitle": "Nieuwe beveiligingssleutel registreren",
|
||||||
"securityKeyRegisterDescription": "Verbind je beveiligingssleutel en voer een naam in om deze te identificeren",
|
"securityKeyRegisterDescription": "Verbind je beveiligingssleutel en voer een naam in om deze te identificeren",
|
||||||
|
|||||||
@@ -1333,7 +1333,7 @@
|
|||||||
"twoFactorRequired": "A autenticação de dois fatores é necessária para registrar uma chave de segurança.",
|
"twoFactorRequired": "A autenticação de dois fatores é necessária para registrar uma chave de segurança.",
|
||||||
"twoFactor": "Autenticação de Dois Fatores",
|
"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.",
|
"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",
|
"securityKeyAdd": "Adicionar Chave de Segurança",
|
||||||
"securityKeyRegisterTitle": "Registrar Nova 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",
|
"securityKeyRegisterDescription": "Conecte sua chave de segurança e insira um nome para identificá-la",
|
||||||
|
|||||||
@@ -1333,7 +1333,7 @@
|
|||||||
"twoFactorRequired": "注册安全密钥需要两步验证。",
|
"twoFactorRequired": "注册安全密钥需要两步验证。",
|
||||||
"twoFactor": "两步验证",
|
"twoFactor": "两步验证",
|
||||||
"adminEnabled2FaOnYourAccount": "管理员已为{email}启用两步验证。请完成设置以继续。",
|
"adminEnabled2FaOnYourAccount": "管理员已为{email}启用两步验证。请完成设置以继续。",
|
||||||
"continueToApplication": "继续到应用程序",
|
"continueToApplication": "继续应用",
|
||||||
"securityKeyAdd": "添加安全密钥",
|
"securityKeyAdd": "添加安全密钥",
|
||||||
"securityKeyRegisterTitle": "注册新安全密钥",
|
"securityKeyRegisterTitle": "注册新安全密钥",
|
||||||
"securityKeyRegisterDescription": "连接您的安全密钥并输入名称以便识别",
|
"securityKeyRegisterDescription": "连接您的安全密钥并输入名称以便识别",
|
||||||
|
|||||||
1219
package-lock.json
generated
1219
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -128,7 +128,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dotenvx/dotenvx": "1.51.0",
|
"@dotenvx/dotenvx": "1.51.0",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@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",
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
"@types/better-sqlite3": "7.6.12",
|
"@types/better-sqlite3": "7.6.12",
|
||||||
"@types/cookie-parser": "1.4.9",
|
"@types/cookie-parser": "1.4.9",
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
"@types/jmespath": "^0.15.2",
|
"@types/jmespath": "^0.15.2",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "24.6.1",
|
"@types/node": "24.6.2",
|
||||||
"@types/nodemailer": "7.0.2",
|
"@types/nodemailer": "7.0.2",
|
||||||
"@types/pg": "8.15.5",
|
"@types/pg": "8.15.5",
|
||||||
"@types/react": "19.1.16",
|
"@types/react": "19.1.16",
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ export const targets = pgTable("targets", {
|
|||||||
path: text("path"),
|
path: text("path"),
|
||||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
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", {
|
export const targetHealthCheck = pgTable("targetHealthCheck", {
|
||||||
@@ -465,6 +466,8 @@ export const resourceRules = pgTable("resourceRules", {
|
|||||||
resourceId: integer("resourceId")
|
resourceId: integer("resourceId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
|
templateRuleId: integer("templateRuleId")
|
||||||
|
.references(() => templateRules.ruleId, { onDelete: "cascade" }),
|
||||||
enabled: boolean("enabled").notNull().default(true),
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
priority: integer("priority").notNull(),
|
priority: integer("priority").notNull(),
|
||||||
action: varchar("action").notNull(), // ACCEPT, DROP, PASS
|
action: varchar("action").notNull(), // ACCEPT, DROP, PASS
|
||||||
@@ -472,6 +475,40 @@ export const resourceRules = pgTable("resourceRules", {
|
|||||||
value: varchar("value").notNull()
|
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", {
|
export const supporterKey = pgTable("supporterKey", {
|
||||||
keyId: serial("keyId").primaryKey(),
|
keyId: serial("keyId").primaryKey(),
|
||||||
key: varchar("key").notNull(),
|
key: varchar("key").notNull(),
|
||||||
@@ -711,3 +748,6 @@ export type SiteResource = InferSelectModel<typeof siteResources>;
|
|||||||
export type SetupToken = InferSelectModel<typeof setupTokens>;
|
export type SetupToken = InferSelectModel<typeof setupTokens>;
|
||||||
export type HostMeta = InferSelectModel<typeof hostMeta>;
|
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>;
|
||||||
|
|||||||
@@ -137,7 +137,8 @@ export const targets = sqliteTable("targets", {
|
|||||||
path: text("path"),
|
path: text("path"),
|
||||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
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", {
|
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||||
@@ -599,6 +600,8 @@ export const resourceRules = sqliteTable("resourceRules", {
|
|||||||
resourceId: integer("resourceId")
|
resourceId: integer("resourceId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
|
templateRuleId: integer("templateRuleId")
|
||||||
|
.references(() => templateRules.ruleId, { onDelete: "cascade" }),
|
||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
priority: integer("priority").notNull(),
|
priority: integer("priority").notNull(),
|
||||||
action: text("action").notNull(), // ACCEPT, DROP, PASS
|
action: text("action").notNull(), // ACCEPT, DROP, PASS
|
||||||
@@ -606,6 +609,40 @@ export const resourceRules = sqliteTable("resourceRules", {
|
|||||||
value: text("value").notNull()
|
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", {
|
export const supporterKey = sqliteTable("supporterKey", {
|
||||||
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
|
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
|
||||||
key: text("key").notNull(),
|
key: text("key").notNull(),
|
||||||
@@ -748,3 +785,6 @@ export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
|||||||
export type SetupToken = InferSelectModel<typeof setupTokens>;
|
export type SetupToken = InferSelectModel<typeof setupTokens>;
|
||||||
export type HostMeta = InferSelectModel<typeof hostMeta>;
|
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>;
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ export async function updateProxyResources(
|
|||||||
path: targetData.path,
|
path: targetData.path,
|
||||||
pathMatchType: targetData["path-match"],
|
pathMatchType: targetData["path-match"],
|
||||||
rewritePath: targetData.rewritePath,
|
rewritePath: targetData.rewritePath,
|
||||||
rewritePathType: targetData["rewrite-match"]
|
rewritePathType: targetData["rewrite-match"],
|
||||||
|
priority: targetData.priority
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -363,7 +364,8 @@ export async function updateProxyResources(
|
|||||||
path: targetData.path,
|
path: targetData.path,
|
||||||
pathMatchType: targetData["path-match"],
|
pathMatchType: targetData["path-match"],
|
||||||
rewritePath: targetData.rewritePath,
|
rewritePath: targetData.rewritePath,
|
||||||
rewritePathType: targetData["rewrite-match"]
|
rewritePathType: targetData["rewrite-match"],
|
||||||
|
priority: targetData.priority
|
||||||
})
|
})
|
||||||
.where(eq(targets.targetId, existingTarget.targetId))
|
.where(eq(targets.targetId, existingTarget.targetId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export const TargetSchema = z.object({
|
|||||||
"path-match": z.enum(["exact", "prefix", "regex"]).optional().nullable(),
|
"path-match": z.enum(["exact", "prefix", "regex"]).optional().nullable(),
|
||||||
healthcheck: TargetHealthCheckSchema.optional(),
|
healthcheck: TargetHealthCheckSchema.optional(),
|
||||||
rewritePath: z.string().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>;
|
export type TargetData = z.infer<typeof TargetSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const configSchema = z
|
|||||||
server: z.object({
|
server: z.object({
|
||||||
integration_port: portSchema
|
integration_port: portSchema
|
||||||
.optional()
|
.optional()
|
||||||
.default(3004)
|
.default(3003)
|
||||||
.transform(stoi)
|
.transform(stoi)
|
||||||
.pipe(portSchema.optional()),
|
.pipe(portSchema.optional()),
|
||||||
external_port: portSchema
|
external_port: portSchema
|
||||||
|
|||||||
@@ -1,81 +1,15 @@
|
|||||||
import { db, exitNodes, targetHealthCheck } from "@server/db";
|
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 logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { orgs, resources, sites, Target, targets } from "@server/db";
|
import { orgs, resources, sites, Target, targets } from "@server/db";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import createPathRewriteMiddleware from "./middleware";
|
import createPathRewriteMiddleware from "./middleware";
|
||||||
|
import { sanitize, validatePathRewriteConfig } from "./utils";
|
||||||
|
|
||||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||||
const badgerMiddlewareName = "badger";
|
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(
|
export async function getTraefikConfig(
|
||||||
exitNodeId: number,
|
exitNodeId: number,
|
||||||
siteTypes: string[],
|
siteTypes: string[],
|
||||||
@@ -99,6 +33,7 @@ export async function getTraefikConfig(
|
|||||||
.select({
|
.select({
|
||||||
// Resource fields
|
// Resource fields
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
|
resourceName: resources.name,
|
||||||
fullDomain: resources.fullDomain,
|
fullDomain: resources.fullDomain,
|
||||||
ssl: resources.ssl,
|
ssl: resources.ssl,
|
||||||
http: resources.http,
|
http: resources.http,
|
||||||
@@ -124,6 +59,8 @@ export async function getTraefikConfig(
|
|||||||
pathMatchType: targets.pathMatchType,
|
pathMatchType: targets.pathMatchType,
|
||||||
rewritePath: targets.rewritePath,
|
rewritePath: targets.rewritePath,
|
||||||
rewritePathType: targets.rewritePathType,
|
rewritePathType: targets.rewritePathType,
|
||||||
|
priority: targets.priority,
|
||||||
|
|
||||||
// Site fields
|
// Site fields
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
siteType: sites.type,
|
siteType: sites.type,
|
||||||
@@ -152,25 +89,29 @@ export async function getTraefikConfig(
|
|||||||
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
|
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
|
||||||
: eq(resources.http, true)
|
: eq(resources.http, true)
|
||||||
)
|
)
|
||||||
);
|
)
|
||||||
|
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
||||||
|
|
||||||
// Group by resource and include targets with their unique site data
|
// Group by resource and include targets with their unique site data
|
||||||
const resourcesMap = new Map();
|
const resourcesMap = new Map();
|
||||||
|
|
||||||
resourcesWithTargetsAndSites.forEach((row) => {
|
resourcesWithTargetsAndSites.forEach((row) => {
|
||||||
const resourceId = row.resourceId;
|
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 pathMatchType = row.pathMatchType || "";
|
||||||
const rewritePath = row.rewritePath || "";
|
const rewritePath = row.rewritePath || "";
|
||||||
const rewritePathType = row.rewritePathType || "";
|
const rewritePathType = row.rewritePathType || "";
|
||||||
|
const priority = row.priority ?? 100;
|
||||||
|
|
||||||
// Create a unique key combining resourceId, path config, and rewrite config
|
// Create a unique key combining resourceId, path config, and rewrite config
|
||||||
const pathKey = [targetPath, pathMatchType, rewritePath, rewritePathType]
|
const pathKey = [targetPath, pathMatchType, rewritePath, rewritePathType]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("-");
|
.join("-");
|
||||||
const mapKey = [resourceId, pathKey].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(
|
const validation = validatePathRewriteConfig(
|
||||||
row.path,
|
row.path,
|
||||||
row.pathMatchType,
|
row.pathMatchType,
|
||||||
@@ -183,8 +124,9 @@ export async function getTraefikConfig(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resourcesMap.set(mapKey, {
|
resourcesMap.set(key, {
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
|
name: resourceName,
|
||||||
fullDomain: row.fullDomain,
|
fullDomain: row.fullDomain,
|
||||||
ssl: row.ssl,
|
ssl: row.ssl,
|
||||||
http: row.http,
|
http: row.http,
|
||||||
@@ -202,12 +144,13 @@ export async function getTraefikConfig(
|
|||||||
path: row.path, // the targets will all have the same path
|
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
|
||||||
rewritePath: row.rewritePath,
|
rewritePath: row.rewritePath,
|
||||||
rewritePathType: row.rewritePathType
|
rewritePathType: row.rewritePathType,
|
||||||
|
priority: priority // may be null, we fallback later
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add target with its associated site data
|
// Add target with its associated site data
|
||||||
resourcesMap.get(mapKey).targets.push({
|
resourcesMap.get(key).targets.push({
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
targetId: row.targetId,
|
targetId: row.targetId,
|
||||||
ip: row.ip,
|
ip: row.ip,
|
||||||
@@ -217,6 +160,7 @@ export async function getTraefikConfig(
|
|||||||
enabled: row.targetEnabled,
|
enabled: row.targetEnabled,
|
||||||
rewritePath: row.rewritePath,
|
rewritePath: row.rewritePath,
|
||||||
rewritePathType: row.rewritePathType,
|
rewritePathType: row.rewritePathType,
|
||||||
|
priority: row.priority,
|
||||||
site: {
|
site: {
|
||||||
siteId: row.siteId,
|
siteId: row.siteId,
|
||||||
type: row.siteType,
|
type: row.siteType,
|
||||||
@@ -248,13 +192,11 @@ export async function getTraefikConfig(
|
|||||||
for (const [key, resource] of resourcesMap.entries()) {
|
for (const [key, resource] of resourcesMap.entries()) {
|
||||||
const targets = resource.targets;
|
const targets = resource.targets;
|
||||||
|
|
||||||
const sanatizedKey = sanitizeForMiddlewareName(key);
|
const routerName = `${key}-${resource.name}-router`;
|
||||||
|
const serviceName = `${key}-${resource.name}-service`;
|
||||||
const routerName = `${sanatizedKey}-router`;
|
|
||||||
const serviceName = `${sanatizedKey}-service`;
|
|
||||||
const fullDomain = `${resource.fullDomain}`;
|
const fullDomain = `${resource.fullDomain}`;
|
||||||
const transportName = `${sanatizedKey}-transport`;
|
const transportName = `${key}-transport`;
|
||||||
const headersMiddlewareName = `${sanatizedKey}-headers-middleware`;
|
const headersMiddlewareName = `${key}-headers-middleware`;
|
||||||
|
|
||||||
if (!resource.enabled) {
|
if (!resource.enabled) {
|
||||||
continue;
|
continue;
|
||||||
@@ -328,7 +270,7 @@ export async function getTraefikConfig(
|
|||||||
resource.rewritePathType) {
|
resource.rewritePathType) {
|
||||||
|
|
||||||
// Create a unique middleware name
|
// Create a unique middleware name
|
||||||
const rewriteMiddlewareName = `rewrite-r${resource.resourceId}-${sanitizeForMiddlewareName(key)}`;
|
const rewriteMiddlewareName = `rewrite-r${resource.resourceId}-${key}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rewriteResult = createPathRewriteMiddleware(
|
const rewriteResult = createPathRewriteMiddleware(
|
||||||
@@ -402,10 +344,30 @@ export async function getTraefikConfig(
|
|||||||
|
|
||||||
// Build routing rules
|
// Build routing rules
|
||||||
let rule = `Host(\`${fullDomain}\`)`;
|
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) {
|
if (resource.path && resource.pathMatchType) {
|
||||||
priority += 1;
|
// priority += 1;
|
||||||
// add path to rule based on match type
|
// add path to rule based on match type
|
||||||
let path = resource.path;
|
let path = resource.path;
|
||||||
// if the path doesn't start with a /, add it
|
// if the path doesn't start with a /, add it
|
||||||
@@ -642,24 +604,3 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
return config_output;
|
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, '');
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
* This file is not licensed under the AGPLv3.
|
* This file is not licensed under the AGPLv3.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Request, Response } from "express";
|
|
||||||
import {
|
import {
|
||||||
certificates,
|
certificates,
|
||||||
db,
|
db,
|
||||||
@@ -20,12 +19,13 @@ import {
|
|||||||
loginPage,
|
loginPage,
|
||||||
targetHealthCheck
|
targetHealthCheck
|
||||||
} from "@server/db";
|
} 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 logger from "@server/logger";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { orgs, resources, sites, Target, targets } from "@server/db";
|
import { orgs, resources, sites, Target, targets } from "@server/db";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { sanitize } from "./utils";
|
||||||
|
|
||||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||||
const redirectToRootMiddlewareName = "redirect-to-root";
|
const redirectToRootMiddlewareName = "redirect-to-root";
|
||||||
@@ -54,6 +54,7 @@ export async function getTraefikConfig(
|
|||||||
.select({
|
.select({
|
||||||
// Resource fields
|
// Resource fields
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
|
resourceName: resources.name,
|
||||||
fullDomain: resources.fullDomain,
|
fullDomain: resources.fullDomain,
|
||||||
ssl: resources.ssl,
|
ssl: resources.ssl,
|
||||||
http: resources.http,
|
http: resources.http,
|
||||||
@@ -77,6 +78,7 @@ export async function getTraefikConfig(
|
|||||||
hcHealth: targetHealthCheck.hcHealth,
|
hcHealth: targetHealthCheck.hcHealth,
|
||||||
path: targets.path,
|
path: targets.path,
|
||||||
pathMatchType: targets.pathMatchType,
|
pathMatchType: targets.pathMatchType,
|
||||||
|
priority: targets.priority,
|
||||||
|
|
||||||
// Site fields
|
// Site fields
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
@@ -118,15 +120,18 @@ export async function getTraefikConfig(
|
|||||||
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
|
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
|
||||||
: eq(resources.http, true)
|
: eq(resources.http, true)
|
||||||
)
|
)
|
||||||
);
|
)
|
||||||
|
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
||||||
|
|
||||||
// Group by resource and include targets with their unique site data
|
// Group by resource and include targets with their unique site data
|
||||||
const resourcesMap = new Map();
|
const resourcesMap = new Map();
|
||||||
|
|
||||||
resourcesWithTargetsAndSites.forEach((row) => {
|
resourcesWithTargetsAndSites.forEach((row) => {
|
||||||
const resourceId = row.resourceId;
|
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 pathMatchType = row.pathMatchType || "";
|
||||||
|
const priority = row.priority ?? 100;
|
||||||
|
|
||||||
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
||||||
return;
|
return;
|
||||||
@@ -135,10 +140,12 @@ export async function getTraefikConfig(
|
|||||||
// Create a unique key combining resourceId and path+pathMatchType
|
// Create a unique key combining resourceId and path+pathMatchType
|
||||||
const pathKey = [targetPath, pathMatchType].filter(Boolean).join("-");
|
const pathKey = [targetPath, pathMatchType].filter(Boolean).join("-");
|
||||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||||
|
const key = sanitize(mapKey);
|
||||||
|
|
||||||
if (!resourcesMap.has(mapKey)) {
|
if (!resourcesMap.has(key)) {
|
||||||
resourcesMap.set(mapKey, {
|
resourcesMap.set(key, {
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
|
name: resourceName,
|
||||||
fullDomain: row.fullDomain,
|
fullDomain: row.fullDomain,
|
||||||
ssl: row.ssl,
|
ssl: row.ssl,
|
||||||
http: row.http,
|
http: row.http,
|
||||||
@@ -155,12 +162,13 @@ export async function getTraefikConfig(
|
|||||||
targets: [],
|
targets: [],
|
||||||
headers: row.headers,
|
headers: row.headers,
|
||||||
path: row.path, // the targets will all have the same path
|
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
|
// Add target with its associated site data
|
||||||
resourcesMap.get(mapKey).targets.push({
|
resourcesMap.get(key).targets.push({
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
targetId: row.targetId,
|
targetId: row.targetId,
|
||||||
ip: row.ip,
|
ip: row.ip,
|
||||||
@@ -168,6 +176,7 @@ export async function getTraefikConfig(
|
|||||||
port: row.port,
|
port: row.port,
|
||||||
internalPort: row.internalPort,
|
internalPort: row.internalPort,
|
||||||
enabled: row.targetEnabled,
|
enabled: row.targetEnabled,
|
||||||
|
priority: row.priority,
|
||||||
site: {
|
site: {
|
||||||
siteId: row.siteId,
|
siteId: row.siteId,
|
||||||
type: row.siteType,
|
type: row.siteType,
|
||||||
@@ -206,8 +215,8 @@ export async function getTraefikConfig(
|
|||||||
for (const [key, resource] of resourcesMap.entries()) {
|
for (const [key, resource] of resourcesMap.entries()) {
|
||||||
const targets = resource.targets;
|
const targets = resource.targets;
|
||||||
|
|
||||||
const routerName = `${key}-router`;
|
const routerName = `${key}-${resource.name}-router`;
|
||||||
const serviceName = `${key}-service`;
|
const serviceName = `${key}-${resource.name}-service`;
|
||||||
const fullDomain = `${resource.fullDomain}`;
|
const fullDomain = `${resource.fullDomain}`;
|
||||||
const transportName = `${key}-transport`;
|
const transportName = `${key}-transport`;
|
||||||
const headersMiddlewareName = `${key}-headers-middleware`;
|
const headersMiddlewareName = `${key}-headers-middleware`;
|
||||||
@@ -331,9 +340,30 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let rule = `Host(\`${fullDomain}\`)`;
|
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) {
|
if (resource.path && resource.pathMatchType) {
|
||||||
priority += 1;
|
//priority += 1;
|
||||||
// add path to rule based on match type
|
// add path to rule based on match type
|
||||||
let path = resource.path;
|
let path = resource.path;
|
||||||
// if the path doesn't start with a /, add it
|
// if the path doesn't start with a /, add it
|
||||||
@@ -389,7 +419,7 @@ export async function getTraefikConfig(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
(targets as TargetWithSite[])
|
(targets as TargetWithSite[])
|
||||||
.filter((target: TargetWithSite) => {
|
.filter((target: TargetWithSite) => {
|
||||||
if (!target.enabled) {
|
if (!target.enabled) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -410,7 +440,7 @@ export async function getTraefikConfig(
|
|||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else if (target.site.type === "newt") {
|
} else if (target.site.type === "newt") {
|
||||||
if (
|
if (
|
||||||
!target.internalPort ||
|
!target.internalPort ||
|
||||||
!target.method ||
|
!target.method ||
|
||||||
@@ -418,10 +448,10 @@ export async function getTraefikConfig(
|
|||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.map((target: TargetWithSite) => {
|
.map((target: TargetWithSite) => {
|
||||||
if (
|
if (
|
||||||
target.site.type === "local" ||
|
target.site.type === "local" ||
|
||||||
target.site.type === "wireguard"
|
target.site.type === "wireguard"
|
||||||
@@ -429,14 +459,14 @@ export async function getTraefikConfig(
|
|||||||
return {
|
return {
|
||||||
url: `${target.method}://${target.ip}:${target.port}`
|
url: `${target.method}://${target.ip}:${target.port}`
|
||||||
};
|
};
|
||||||
} else if (target.site.type === "newt") {
|
} else if (target.site.type === "newt") {
|
||||||
const ip =
|
const ip =
|
||||||
target.site.subnet!.split("/")[0];
|
target.site.subnet!.split("/")[0];
|
||||||
return {
|
return {
|
||||||
url: `${target.method}://${ip}:${target.internalPort}`
|
url: `${target.method}://${ip}:${target.internalPort}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// filter out duplicates
|
// filter out duplicates
|
||||||
.filter(
|
.filter(
|
||||||
(v, i, a) =>
|
(v, i, a) =>
|
||||||
@@ -680,13 +710,3 @@ export async function getTraefikConfig(
|
|||||||
|
|
||||||
return config_output;
|
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, "");
|
|
||||||
}
|
|
||||||
|
|||||||
81
server/lib/traefik/utils.ts
Normal file
81
server/lib/traefik/utils.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ export enum OpenAPITags {
|
|||||||
Invitation = "Invitation",
|
Invitation = "Invitation",
|
||||||
Target = "Target",
|
Target = "Target",
|
||||||
Rule = "Rule",
|
Rule = "Rule",
|
||||||
|
RuleTemplate = "Rule Template",
|
||||||
AccessToken = "Access Token",
|
AccessToken = "Access Token",
|
||||||
Idp = "Identity Provider",
|
Idp = "Identity Provider",
|
||||||
Client = "Client",
|
Client = "Client",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import * as accessToken from "./accessToken";
|
|||||||
import * as idp from "./idp";
|
import * as idp from "./idp";
|
||||||
import * as license from "./license";
|
import * as license from "./license";
|
||||||
import * as apiKeys from "./apiKeys";
|
import * as apiKeys from "./apiKeys";
|
||||||
|
import * as ruleTemplate from "./ruleTemplate";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import {
|
import {
|
||||||
verifyAccessTokenAccess,
|
verifyAccessTokenAccess,
|
||||||
@@ -453,6 +454,80 @@ authenticated.delete(
|
|||||||
resource.deleteResourceRule
|
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(
|
authenticated.get(
|
||||||
"/target/:targetId",
|
"/target/:targetId",
|
||||||
verifyTargetAccess,
|
verifyTargetAccess,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function queryResourceRules(resourceId: number) {
|
|||||||
.select({
|
.select({
|
||||||
ruleId: resourceRules.ruleId,
|
ruleId: resourceRules.ruleId,
|
||||||
resourceId: resourceRules.resourceId,
|
resourceId: resourceRules.resourceId,
|
||||||
|
templateRuleId: resourceRules.templateRuleId,
|
||||||
action: resourceRules.action,
|
action: resourceRules.action,
|
||||||
match: resourceRules.match,
|
match: resourceRules.match,
|
||||||
value: resourceRules.value,
|
value: resourceRules.value,
|
||||||
|
|||||||
161
server/routers/ruleTemplate/addTemplateRule.ts
Normal file
161
server/routers/ruleTemplate/addTemplateRule.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
176
server/routers/ruleTemplate/assignTemplateToResource.ts
Normal file
176
server/routers/ruleTemplate/assignTemplateToResource.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
server/routers/ruleTemplate/createRuleTemplate.ts
Normal file
121
server/routers/ruleTemplate/createRuleTemplate.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
server/routers/ruleTemplate/deleteRuleTemplate.ts
Normal file
79
server/routers/ruleTemplate/deleteRuleTemplate.ts
Normal 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"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
114
server/routers/ruleTemplate/deleteTemplateRule.ts
Normal file
114
server/routers/ruleTemplate/deleteTemplateRule.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
server/routers/ruleTemplate/getRuleTemplate.ts
Normal file
77
server/routers/ruleTemplate/getRuleTemplate.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
server/routers/ruleTemplate/index.ts
Normal file
12
server/routers/ruleTemplate/index.ts
Normal 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";
|
||||||
104
server/routers/ruleTemplate/listResourceTemplates.ts
Normal file
104
server/routers/ruleTemplate/listResourceTemplates.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
server/routers/ruleTemplate/listRuleTemplates.ts
Normal file
127
server/routers/ruleTemplate/listRuleTemplates.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
server/routers/ruleTemplate/listTemplateRules.ts
Normal file
73
server/routers/ruleTemplate/listTemplateRules.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
server/routers/ruleTemplate/unassignTemplateFromResource.ts
Normal file
130
server/routers/ruleTemplate/unassignTemplateFromResource.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
server/routers/ruleTemplate/updateRuleTemplate.ts
Normal file
117
server/routers/ruleTemplate/updateRuleTemplate.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
194
server/routers/ruleTemplate/updateTemplateRule.ts
Normal file
194
server/routers/ruleTemplate/updateTemplateRule.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,8 @@ const createTargetSchema = z
|
|||||||
path: z.string().optional().nullable(),
|
path: z.string().optional().nullable(),
|
||||||
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
|
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
|
||||||
rewritePath: z.string().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();
|
.strict();
|
||||||
|
|
||||||
@@ -210,7 +211,10 @@ export async function createTarget(
|
|||||||
internalPort,
|
internalPort,
|
||||||
enabled: targetData.enabled,
|
enabled: targetData.enabled,
|
||||||
path: targetData.path,
|
path: targetData.path,
|
||||||
pathMatchType: targetData.pathMatchType
|
pathMatchType: targetData.pathMatchType,
|
||||||
|
rewritePath: targetData.rewritePath,
|
||||||
|
rewritePathType: targetData.rewritePathType,
|
||||||
|
priority: targetData.priority
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ function queryTargets(resourceId: number) {
|
|||||||
path: targets.path,
|
path: targets.path,
|
||||||
pathMatchType: targets.pathMatchType,
|
pathMatchType: targets.pathMatchType,
|
||||||
rewritePath: targets.rewritePath,
|
rewritePath: targets.rewritePath,
|
||||||
rewritePathType: targets.rewritePathType
|
rewritePathType: targets.rewritePathType,
|
||||||
|
priority: targets.priority,
|
||||||
})
|
})
|
||||||
.from(targets)
|
.from(targets)
|
||||||
.leftJoin(sites, eq(sites.siteId, targets.siteId))
|
.leftJoin(sites, eq(sites.siteId, targets.siteId))
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ const updateTargetBodySchema = z
|
|||||||
path: z.string().optional().nullable(),
|
path: z.string().optional().nullable(),
|
||||||
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
|
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
|
||||||
rewritePath: z.string().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()
|
.strict()
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
@@ -198,7 +199,10 @@ export async function updateTarget(
|
|||||||
internalPort,
|
internalPort,
|
||||||
enabled: parsedBody.data.enabled,
|
enabled: parsedBody.data.enabled,
|
||||||
path: parsedBody.data.path,
|
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))
|
.where(eq(targets.targetId, targetId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|||||||
63
server/setup/scriptsPg/1.11.0.ts
Normal file
63
server/setup/scriptsPg/1.11.0.ts
Normal 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`);
|
||||||
|
}
|
||||||
70
server/setup/scriptsSqlite/1.11.0.ts
Normal file
70
server/setup/scriptsSqlite/1.11.0.ts
Normal 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`);
|
||||||
|
}
|
||||||
@@ -74,7 +74,10 @@ import {
|
|||||||
CircleX,
|
CircleX,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Plus,
|
Plus,
|
||||||
MoveRight
|
MoveRight,
|
||||||
|
ArrowUp,
|
||||||
|
Info,
|
||||||
|
ArrowDown
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ContainersSelector } from "@app/components/ContainersSelector";
|
import { ContainersSelector } from "@app/components/ContainersSelector";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -106,6 +109,7 @@ import {
|
|||||||
PathRewriteModal
|
PathRewriteModal
|
||||||
} from "@app/components/PathMatchRenameModal";
|
} from "@app/components/PathMatchRenameModal";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip";
|
||||||
|
|
||||||
const addTargetSchema = z
|
const addTargetSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -122,7 +126,8 @@ const addTargetSchema = z
|
|||||||
rewritePathType: z
|
rewritePathType: z
|
||||||
.enum(["exact", "prefix", "regex", "stripPrefix"])
|
.enum(["exact", "prefix", "regex", "stripPrefix"])
|
||||||
.optional()
|
.optional()
|
||||||
.nullable()
|
.nullable(),
|
||||||
|
priority: z.number().int().min(1).max(1000)
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
@@ -301,7 +306,8 @@ export default function ReverseProxyTargets(props: {
|
|||||||
path: null,
|
path: null,
|
||||||
pathMatchType: null,
|
pathMatchType: null,
|
||||||
rewritePath: null,
|
rewritePath: null,
|
||||||
rewritePathType: null
|
rewritePathType: null,
|
||||||
|
priority: 100
|
||||||
} as z.infer<typeof addTargetSchema>
|
} as z.infer<typeof addTargetSchema>
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -485,6 +491,7 @@ export default function ReverseProxyTargets(props: {
|
|||||||
targetId: new Date().getTime(),
|
targetId: new Date().getTime(),
|
||||||
new: true,
|
new: true,
|
||||||
resourceId: resource.resourceId,
|
resourceId: resource.resourceId,
|
||||||
|
priority: 100,
|
||||||
hcEnabled: false,
|
hcEnabled: false,
|
||||||
hcPath: null,
|
hcPath: null,
|
||||||
hcMethod: null,
|
hcMethod: null,
|
||||||
@@ -509,7 +516,8 @@ export default function ReverseProxyTargets(props: {
|
|||||||
path: null,
|
path: null,
|
||||||
pathMatchType: null,
|
pathMatchType: null,
|
||||||
rewritePath: null,
|
rewritePath: null,
|
||||||
rewritePathType: null
|
rewritePathType: null,
|
||||||
|
priority: 100,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,7 +595,8 @@ export default function ReverseProxyTargets(props: {
|
|||||||
path: target.path,
|
path: target.path,
|
||||||
pathMatchType: target.pathMatchType,
|
pathMatchType: target.pathMatchType,
|
||||||
rewritePath: target.rewritePath,
|
rewritePath: target.rewritePath,
|
||||||
rewritePathType: target.rewritePathType
|
rewritePathType: target.rewritePathType,
|
||||||
|
priority: target.priority
|
||||||
};
|
};
|
||||||
|
|
||||||
if (target.new) {
|
if (target.new) {
|
||||||
@@ -660,6 +669,46 @@ export default function ReverseProxyTargets(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnDef<LocalTarget>[] = [
|
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",
|
accessorKey: "path",
|
||||||
header: t("matchPath"),
|
header: t("matchPath"),
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ import {
|
|||||||
} from "@app/components/ui/popover";
|
} from "@app/components/ui/popover";
|
||||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
||||||
import { cn } from "@app/lib/cn";
|
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 CopyTextBox from "@app/components/CopyTextBox";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -92,6 +92,7 @@ import { parseHostTarget } from "@app/lib/parseHostTarget";
|
|||||||
import { toASCII, toUnicode } from 'punycode';
|
import { toASCII, toUnicode } from 'punycode';
|
||||||
import { DomainRow } from "../../../../../components/DomainsTable";
|
import { DomainRow } from "../../../../../components/DomainsTable";
|
||||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
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";
|
import { PathMatchDisplay, PathMatchModal, PathRewriteDisplay, PathRewriteModal } from "@app/components/PathMatchRenameModal";
|
||||||
|
|
||||||
|
|
||||||
@@ -119,7 +120,8 @@ const addTargetSchema = z.object({
|
|||||||
path: z.string().optional().nullable(),
|
path: z.string().optional().nullable(),
|
||||||
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
|
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
|
||||||
rewritePath: z.string().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(
|
}).refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
// If path is provided, pathMatchType must be provided
|
// If path is provided, pathMatchType must be provided
|
||||||
@@ -262,6 +264,7 @@ export default function Page() {
|
|||||||
pathMatchType: null,
|
pathMatchType: null,
|
||||||
rewritePath: null,
|
rewritePath: null,
|
||||||
rewritePathType: null,
|
rewritePathType: null,
|
||||||
|
priority: 100,
|
||||||
} as z.infer<typeof addTargetSchema>
|
} as z.infer<typeof addTargetSchema>
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -341,6 +344,7 @@ export default function Page() {
|
|||||||
targetId: new Date().getTime(),
|
targetId: new Date().getTime(),
|
||||||
new: true,
|
new: true,
|
||||||
resourceId: 0, // Will be set when resource is created
|
resourceId: 0, // Will be set when resource is created
|
||||||
|
priority: 100, // Default priority
|
||||||
hcEnabled: false,
|
hcEnabled: false,
|
||||||
hcPath: null,
|
hcPath: null,
|
||||||
hcMethod: null,
|
hcMethod: null,
|
||||||
@@ -366,6 +370,7 @@ export default function Page() {
|
|||||||
pathMatchType: null,
|
pathMatchType: null,
|
||||||
rewritePath: null,
|
rewritePath: null,
|
||||||
rewritePathType: null,
|
rewritePathType: null,
|
||||||
|
priority: 100,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,7 +480,8 @@ export default function Page() {
|
|||||||
path: target.path,
|
path: target.path,
|
||||||
pathMatchType: target.pathMatchType,
|
pathMatchType: target.pathMatchType,
|
||||||
rewritePath: target.rewritePath,
|
rewritePath: target.rewritePath,
|
||||||
rewritePathType: target.rewritePathType
|
rewritePathType: target.rewritePathType,
|
||||||
|
priority: target.priority
|
||||||
};
|
};
|
||||||
|
|
||||||
await api.put(`/resource/${id}/target`, data);
|
await api.put(`/resource/${id}/target`, data);
|
||||||
@@ -598,6 +604,46 @@ export default function Page() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const columns: ColumnDef<LocalTarget>[] = [
|
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",
|
accessorKey: "path",
|
||||||
header: t("matchPath"),
|
header: t("matchPath"),
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { DataTable } from "@app/components/ui/data-table";
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
interface DataTableProps<TData, TValue> {
|
||||||
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
createTemplate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuleTemplatesDataTable<TData, TValue>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
createTemplate
|
||||||
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
title={t('ruleTemplates')}
|
||||||
|
searchPlaceholder={t('ruleTemplatesSearch')}
|
||||||
|
searchColumn="name"
|
||||||
|
onAdd={createTemplate}
|
||||||
|
addButtonText={t('ruleTemplateAdd')}
|
||||||
|
defaultSort={{
|
||||||
|
id: "name",
|
||||||
|
desc: false
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
272
src/app/[orgId]/settings/rule-templates/RuleTemplatesTable.tsx
Normal file
272
src/app/[orgId]/settings/rule-templates/RuleTemplatesTable.tsx
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { RuleTemplatesDataTable } from "./RuleTemplatesDataTable";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@app/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
ArrowUpDown,
|
||||||
|
MoreHorizontal,
|
||||||
|
Trash2,
|
||||||
|
Plus
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
export type TemplateRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
orgId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RuleTemplatesTableProps = {
|
||||||
|
templates: TemplateRow[];
|
||||||
|
orgId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTemplateSchema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required").max(100, "Name must be less than 100 characters"),
|
||||||
|
description: z.string().max(500, "Description must be less than 500 characters").optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export function RuleTemplatesTable({ templates, orgId }: RuleTemplatesTableProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const t = useTranslations();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<TemplateRow | null>(null);
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof createTemplateSchema>>({
|
||||||
|
resolver: zodResolver(createTemplateSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
description: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteTemplate = (templateId: string) => {
|
||||||
|
api.delete(`/org/${orgId}/rule-templates/${templateId}`)
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to delete template:", e);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("ruleTemplateErrorDelete"),
|
||||||
|
description: formatAxiosError(e, t("ruleTemplateErrorDelete"))
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
router.refresh();
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTemplate = async (values: z.infer<typeof createTemplateSchema>) => {
|
||||||
|
try {
|
||||||
|
const response = await api.post(`/org/${orgId}/rule-templates`, values);
|
||||||
|
|
||||||
|
if (response.status === 201) {
|
||||||
|
setIsCreateDialogOpen(false);
|
||||||
|
form.reset();
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Rule template created successfully"
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: response.data.message || "Failed to create rule template",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: formatAxiosError(error, "Failed to create rule template"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<TemplateRow>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "description",
|
||||||
|
header: "Description",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const template = row.original;
|
||||||
|
return (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{template.description || "No description provided"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const template = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTemplate(template);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
<span className="text-red-500">Delete</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Link
|
||||||
|
href={`/${template.orgId}/settings/rule-templates/${template.id}`}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="ml-2"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{selectedTemplate && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
setOpen={(val) => {
|
||||||
|
setIsDeleteModalOpen(val);
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
}}
|
||||||
|
dialog={
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">
|
||||||
|
Are you sure you want to delete the template "{selectedTemplate?.name}"?
|
||||||
|
</p>
|
||||||
|
<p className="mb-2">This action cannot be undone and will remove all rules associated with this template.</p>
|
||||||
|
<p className="mb-2">This will also unassign the template from any resources that are using it.</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
To confirm, please type <span className="font-mono font-medium">{selectedTemplate?.name}</span> below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
buttonText="Delete Template"
|
||||||
|
onConfirm={async () => deleteTemplate(selectedTemplate!.id)}
|
||||||
|
string={selectedTemplate.name}
|
||||||
|
title="Delete Rule Template"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Template Dialog */}
|
||||||
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Rule Template</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new rule template to define access control rules
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(handleCreateTemplate)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Enter template name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Enter template description (optional)"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">Create Template</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<RuleTemplatesDataTable
|
||||||
|
columns={columns}
|
||||||
|
data={templates}
|
||||||
|
createTemplate={() => setIsCreateDialogOpen(true)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { GetRuleTemplateResponse } from "@server/routers/ruleTemplate";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { GetOrgResponse } from "@server/routers/org";
|
||||||
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
|
import { cache } from "react";
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
interface RuleTemplateLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ templateId: string; orgId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RuleTemplateLayout(props: RuleTemplateLayoutProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
let template = null;
|
||||||
|
try {
|
||||||
|
const res = await internal.get<AxiosResponse<GetRuleTemplateResponse>>(
|
||||||
|
`/org/${params.orgId}/rule-templates/${params.templateId}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
template = res.data.data;
|
||||||
|
} catch {
|
||||||
|
redirect(`/${params.orgId}/settings/rule-templates`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
redirect(`/${params.orgId}/settings/rule-templates`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let org = null;
|
||||||
|
try {
|
||||||
|
const getOrg = cache(async () =>
|
||||||
|
internal.get<AxiosResponse<GetOrgResponse>>(
|
||||||
|
`/org/${params.orgId}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const res = await getOrg();
|
||||||
|
org = res.data.data;
|
||||||
|
} catch {
|
||||||
|
redirect(`/${params.orgId}/settings/rule-templates`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
redirect(`/${params.orgId}/settings/rule-templates`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
title: t('general'),
|
||||||
|
href: `/{orgId}/settings/rule-templates/{templateId}/general`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('rules'),
|
||||||
|
href: `/{orgId}/settings/rule-templates/{templateId}/rules`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={t('ruleTemplateSetting', {templateName: template?.name})}
|
||||||
|
description={t('ruleTemplateSettingDescription')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OrgProvider org={org}>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<HorizontalTabs items={navItems}>
|
||||||
|
{children}
|
||||||
|
</HorizontalTabs>
|
||||||
|
</div>
|
||||||
|
</OrgProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function RuleTemplatePage(props: {
|
||||||
|
params: Promise<{ templateId: string; orgId: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await props.params;
|
||||||
|
redirect(
|
||||||
|
`/${params.orgId}/settings/rule-templates/${params.templateId}/general`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/app/[orgId]/settings/rule-templates/page.tsx
Normal file
72
src/app/[orgId]/settings/rule-templates/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { cache } from "react";
|
||||||
|
import { GetOrgResponse } from "@server/routers/org";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { RuleTemplatesTable } from "./RuleTemplatesTable";
|
||||||
|
|
||||||
|
type RuleTemplatesPageProps = {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function RuleTemplatesPage(props: RuleTemplatesPageProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
let templates: any[] = [];
|
||||||
|
try {
|
||||||
|
const res = await internal.get<AxiosResponse<any>>(
|
||||||
|
`/org/${params.orgId}/rule-templates`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
templates = res.data.data.templates || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch rule templates:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
let org = null;
|
||||||
|
try {
|
||||||
|
const getOrg = cache(async () =>
|
||||||
|
internal.get<AxiosResponse<GetOrgResponse>>(
|
||||||
|
`/org/${params.orgId}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const res = await getOrg();
|
||||||
|
org = res.data.data;
|
||||||
|
} catch {
|
||||||
|
redirect(`/${params.orgId}/settings/rule-templates`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
redirect(`/${params.orgId}/settings/rule-templates`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateRows = templates.map((template) => {
|
||||||
|
return {
|
||||||
|
id: template.templateId,
|
||||||
|
name: template.name,
|
||||||
|
description: template.description || "",
|
||||||
|
orgId: params.orgId
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title="Rule Templates"
|
||||||
|
description="Create and manage rule templates for consistent access control across your resources"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OrgProvider org={org}>
|
||||||
|
<RuleTemplatesTable templates={templateRows} orgId={params.orgId} />
|
||||||
|
</OrgProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
Globe, // Added from 'dev' branch
|
Globe, // Added from 'dev' branch
|
||||||
MonitorUp, // Added from 'dev' branch
|
MonitorUp, // Added from 'dev' branch
|
||||||
Server,
|
Server,
|
||||||
Zap
|
Zap,
|
||||||
|
Shield
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export type SidebarNavSection = {
|
export type SidebarNavSection = {
|
||||||
@@ -105,6 +106,11 @@ export const orgNavSections = (
|
|||||||
title: "sidebarShareableLinks",
|
title: "sidebarShareableLinks",
|
||||||
href: "/{orgId}/settings/share-links",
|
href: "/{orgId}/settings/share-links",
|
||||||
icon: <LinkIcon className="h-4 w-4" />
|
icon: <LinkIcon className="h-4 w-4" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "sidebarRuleTemplates",
|
||||||
|
href: "/{orgId}/settings/rule-templates",
|
||||||
|
icon: <Shield className="h-4 w-4" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
78
src/components/ConfirmationDialog.tsx
Normal file
78
src/components/ConfirmationDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ export function HorizontalTabs({
|
|||||||
return href
|
return href
|
||||||
.replace("{orgId}", params.orgId as string)
|
.replace("{orgId}", params.orgId as string)
|
||||||
.replace("{resourceId}", params.resourceId as string)
|
.replace("{resourceId}", params.resourceId as string)
|
||||||
|
.replace("{templateId}", params.templateId as string)
|
||||||
.replace("{niceId}", params.niceId as string)
|
.replace("{niceId}", params.niceId as string)
|
||||||
.replace("{userId}", params.userId as string)
|
.replace("{userId}", params.userId as string)
|
||||||
.replace("{clientId}", params.clientId as string)
|
.replace("{clientId}", params.clientId as string)
|
||||||
|
|||||||
224
src/components/ruleTemplate/ResourceRulesManager.tsx
Normal file
224
src/components/ruleTemplate/ResourceRulesManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
626
src/components/ruleTemplate/TemplateRulesManager.tsx
Normal file
626
src/components/ruleTemplate/TemplateRulesManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user