From 4d792350efa672bf988f386ddc4418c40b9d7b23 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 12 Feb 2026 02:53:04 +0100 Subject: [PATCH 01/89] =?UTF-8?q?=F0=9F=97=83=EF=B8=8F=20add=20resource=20?= =?UTF-8?q?policy=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 51 ++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 3c9574704..8363796cf 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -187,7 +187,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", { hcFollowRedirects: boolean("hcFollowRedirects").default(true), hcMethod: varchar("hcMethod").default("GET"), hcStatus: integer("hcStatus"), // http code - hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy" + hcHealth: text("hcHealth") + .$type<"unknown" | "healthy" | "unhealthy">() + .default("unknown"), // "unknown", "healthy", "unhealthy" hcTlsServerName: text("hcTlsServerName") }); @@ -217,7 +219,7 @@ export const siteResources = pgTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), - mode: varchar("mode").notNull(), // "host" | "cidr" | "port" + mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" protocol: varchar("protocol"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode @@ -417,7 +419,10 @@ export const roleResources = pgTable("roleResources", { .references(() => roles.roleId, { onDelete: "cascade" }), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }) + .references(() => resources.resourceId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + // .notNull() + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), }); export const userResources = pgTable("userResources", { @@ -426,7 +431,10 @@ export const userResources = pgTable("userResources", { .references(() => users.userId, { onDelete: "cascade" }), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }) + .references(() => resources.resourceId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + // .notNull() + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), }); export const userInvites = pgTable("userInvites", { @@ -448,7 +456,10 @@ export const resourcePincode = pgTable("resourcePincode", { .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), pincodeHash: varchar("pincodeHash").notNull(), - digitLength: integer("digitLength").notNull() + digitLength: integer("digitLength").notNull(), + resourcePolicyId: integer("resourcePolicyId") + // .notNull() + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), }); export const resourcePassword = pgTable("resourcePassword", { @@ -456,7 +467,10 @@ export const resourcePassword = pgTable("resourcePassword", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), - passwordHash: varchar("passwordHash").notNull() + passwordHash: varchar("passwordHash").notNull(), + resourcePolicyId: integer("resourcePolicyId") + // .notNull() + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), }); export const resourceHeaderAuth = pgTable("resourceHeaderAuth", { @@ -464,7 +478,10 @@ export const resourceHeaderAuth = pgTable("resourceHeaderAuth", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), - headerAuthHash: varchar("headerAuthHash").notNull() + headerAuthHash: varchar("headerAuthHash").notNull(), + resourcePolicyId: integer("resourcePolicyId") + // .notNull() + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), }); export const resourceHeaderAuthExtendedCompatibility = pgTable( @@ -476,6 +493,9 @@ export const resourceHeaderAuthExtendedCompatibility = pgTable( resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + // .notNull() + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), extendedCompatibilityIsActivated: boolean( "extendedCompatibilityIsActivated" ) @@ -570,6 +590,9 @@ export const resourceRules = pgTable("resourceRules", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + // .notNull() + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), enabled: boolean("enabled").notNull().default(true), priority: integer("priority").notNull(), action: varchar("action").notNull(), // ACCEPT, DROP, PASS @@ -577,6 +600,19 @@ export const resourceRules = pgTable("resourceRules", { value: varchar("value").notNull() }); +export const resourcePolicies = pgTable("resourcePolicies", { + resourcePolicyId: serial('resourcePolicyId').primaryKey(), + idpId: integer("idpId").references(() => idp.idpId, { + onDelete: "set null" + }), + name: varchar("name").notNull(), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), +}); + export const supporterKey = pgTable("supporterKey", { keyId: serial("keyId").primaryKey(), key: varchar("key").notNull(), @@ -1043,3 +1079,4 @@ export type SecurityKey = InferSelectModel; export type WebauthnChallenge = InferSelectModel; export type DeviceWebAuthCode = InferSelectModel; export type RequestAuditLog = InferSelectModel; +export type ResourcePolicy = InferSelectModel; From 3cb9e02533c01ef30dabc022c26e3d03e92f0ff7 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 12 Feb 2026 02:56:45 +0100 Subject: [PATCH 02/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20make=20`resourcePoli?= =?UTF-8?q?cyId`=20non=20nullable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 8363796cf..864aa7eb2 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -421,7 +421,7 @@ export const roleResources = pgTable("roleResources", { .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), resourcePolicyId: integer("resourcePolicyId") - // .notNull() + .notNull() .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), }); @@ -433,7 +433,7 @@ export const userResources = pgTable("userResources", { .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), resourcePolicyId: integer("resourcePolicyId") - // .notNull() + .notNull() .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), }); @@ -458,7 +458,7 @@ export const resourcePincode = pgTable("resourcePincode", { pincodeHash: varchar("pincodeHash").notNull(), digitLength: integer("digitLength").notNull(), resourcePolicyId: integer("resourcePolicyId") - // .notNull() + .notNull() .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), }); @@ -469,7 +469,7 @@ export const resourcePassword = pgTable("resourcePassword", { .references(() => resources.resourceId, { onDelete: "cascade" }), passwordHash: varchar("passwordHash").notNull(), resourcePolicyId: integer("resourcePolicyId") - // .notNull() + .notNull() .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), }); @@ -480,7 +480,7 @@ export const resourceHeaderAuth = pgTable("resourceHeaderAuth", { .references(() => resources.resourceId, { onDelete: "cascade" }), headerAuthHash: varchar("headerAuthHash").notNull(), resourcePolicyId: integer("resourcePolicyId") - // .notNull() + .notNull() .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), }); @@ -494,7 +494,7 @@ export const resourceHeaderAuthExtendedCompatibility = pgTable( .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), resourcePolicyId: integer("resourcePolicyId") - // .notNull() + .notNull() .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), extendedCompatibilityIsActivated: boolean( "extendedCompatibilityIsActivated" @@ -591,7 +591,7 @@ export const resourceRules = pgTable("resourceRules", { .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), resourcePolicyId: integer("resourcePolicyId") - // .notNull() + .notNull() .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), enabled: boolean("enabled").notNull().default(true), priority: integer("priority").notNull(), From f6590aedbd56f88d5635731d1950c04f7219c1ef Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 12 Feb 2026 03:22:24 +0100 Subject: [PATCH 03/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20add=20default=20`sso?= =?UTF-8?q?:=20true`=20to=20resource=20policy=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 864aa7eb2..3d3751931 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -602,6 +602,7 @@ export const resourceRules = pgTable("resourceRules", { export const resourcePolicies = pgTable("resourcePolicies", { resourcePolicyId: serial('resourcePolicyId').primaryKey(), + sso: boolean("sso").notNull().default(true), idpId: integer("idpId").references(() => idp.idpId, { onDelete: "set null" }), From e6fd4c32c4d8345c6017e49dde3f79e82f38285d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 12 Feb 2026 03:50:09 +0100 Subject: [PATCH 04/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20update=20DB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 3d3751931..59d6252a8 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -96,6 +96,8 @@ export const sites = pgTable("sites", { export const resources = pgTable("resources", { resourceId: serial("resourceId").primaryKey(), + resourcePolicyId: integer("resourcePolicyId") + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), resourceGuid: varchar("resourceGuid", { length: 36 }) .unique() .notNull() @@ -567,7 +569,10 @@ export const resourceWhitelist = pgTable("resourceWhitelist", { email: varchar("email").notNull(), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }) + .references(() => resources.resourceId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), }); export const resourceOtp = pgTable("resourceOtp", { @@ -575,6 +580,9 @@ export const resourceOtp = pgTable("resourceOtp", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), email: varchar("email").notNull(), otpHash: varchar("otpHash").notNull(), expiresAt: bigint("expiresAt", { mode: "number" }).notNull() From e7df24841eb45b78cf30fbff8afa1e0efaa557c1 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 12 Feb 2026 03:50:30 +0100 Subject: [PATCH 05/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20update=20sqlite=20DB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/sqlite/schema/schema.ts | 50 +++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 4137db3cb..6a2949dfb 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -106,6 +106,8 @@ export const sites = sqliteTable("sites", { export const resources = sqliteTable("resources", { resourceId: integer("resourceId").primaryKey({ autoIncrement: true }), + resourcePolicyId: integer("resourcePolicyId") + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), resourceGuid: text("resourceGuid", { length: 36 }) .unique() .notNull() @@ -747,7 +749,10 @@ export const roleResources = sqliteTable("roleResources", { .references(() => roles.roleId, { onDelete: "cascade" }), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }) + .references(() => resources.resourceId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), }); export const userResources = sqliteTable("userResources", { @@ -756,7 +761,10 @@ export const userResources = sqliteTable("userResources", { .references(() => users.userId, { onDelete: "cascade" }), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }) + .references(() => resources.resourceId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), }); export const userInvites = sqliteTable("userInvites", { @@ -779,6 +787,9 @@ export const resourcePincode = sqliteTable("resourcePincode", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), pincodeHash: text("pincodeHash").notNull(), digitLength: integer("digitLength").notNull() }); @@ -790,6 +801,9 @@ export const resourcePassword = sqliteTable("resourcePassword", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), passwordHash: text("passwordHash").notNull() }); @@ -800,6 +814,9 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), headerAuthHash: text("headerAuthHash").notNull() }); @@ -814,6 +831,9 @@ export const resourceHeaderAuthExtendedCompatibility = sqliteTable( resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), extendedCompatibilityIsActivated: integer( "extendedCompatibilityIsActivated", { mode: "boolean" } @@ -885,7 +905,10 @@ export const resourceWhitelist = sqliteTable("resourceWhitelist", { email: text("email").notNull(), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }) + .references(() => resources.resourceId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), }); export const resourceOtp = sqliteTable("resourceOtp", { @@ -895,6 +918,9 @@ export const resourceOtp = sqliteTable("resourceOtp", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), email: text("email").notNull(), otpHash: text("otpHash").notNull(), expiresAt: integer("expiresAt").notNull() @@ -910,6 +936,9 @@ export const resourceRules = sqliteTable("resourceRules", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), priority: integer("priority").notNull(), action: text("action").notNull(), // ACCEPT, DROP, PASS @@ -917,6 +946,21 @@ export const resourceRules = sqliteTable("resourceRules", { value: text("value").notNull() }); +export const resourcePolicies = sqliteTable("resourcePolicies", { + resourcePolicyId: integer('resourcePolicyId').primaryKey(), + sso: integer("sso", { mode: 'boolean' }).notNull().default(true), + idpId: integer("idpId").references(() => idp.idpId, { + onDelete: "set null" + }), + name: text("name").notNull(), + orgId: text("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), +}); + + export const supporterKey = sqliteTable("supporterKey", { keyId: integer("keyId").primaryKey({ autoIncrement: true }), key: text("key").notNull(), From 51aa55f963c0f1fb2a666d5a3c478b30fc83e2d3 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 13 Feb 2026 00:25:00 +0100 Subject: [PATCH 06/89] =?UTF-8?q?=E2=8F=AA=20revert=20changes=20already=20?= =?UTF-8?q?included=20in=20another=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 59d6252a8..2cc59f737 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -189,9 +189,7 @@ export const targetHealthCheck = pgTable("targetHealthCheck", { hcFollowRedirects: boolean("hcFollowRedirects").default(true), hcMethod: varchar("hcMethod").default("GET"), hcStatus: integer("hcStatus"), // http code - hcHealth: text("hcHealth") - .$type<"unknown" | "healthy" | "unhealthy">() - .default("unknown"), // "unknown", "healthy", "unhealthy" + hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy" hcTlsServerName: text("hcTlsServerName") }); @@ -221,7 +219,7 @@ export const siteResources = pgTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), - mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" + mode: varchar("mode").notNull(), // "host" | "cidr" | "port" protocol: varchar("protocol"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode From c3db8b972f45dfa3442b1d6a59442e499a632b0c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 13 Feb 2026 05:36:42 +0100 Subject: [PATCH 07/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20schema=20updates=20f?= =?UTF-8?q?or=20policies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 3 +++ server/db/sqlite/schema/schema.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 1c12bdfdc..b3b6534e7 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -610,9 +610,12 @@ export const resourceRules = pgTable("resourceRules", { export const resourcePolicies = pgTable("resourcePolicies", { resourcePolicyId: serial('resourcePolicyId').primaryKey(), sso: boolean("sso").notNull().default(true), + emailWhitelistEnabled: boolean("emailWhitelistEnabled").notNull().default(false), idpId: integer("idpId").references(() => idp.idpId, { onDelete: "set null" }), + niceId: text("niceId").notNull(), + isDefault: boolean("isDefault").notNull().default(true), name: varchar("name").notNull(), orgId: varchar("orgId") .references(() => orgs.orgId, { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 0ff784ca4..df188213c 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -950,6 +950,9 @@ export const resourceRules = sqliteTable("resourceRules", { export const resourcePolicies = sqliteTable("resourcePolicies", { resourcePolicyId: integer('resourcePolicyId').primaryKey(), sso: integer("sso", { mode: 'boolean' }).notNull().default(true), + emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: 'boolean' }).notNull().default(false), + niceId: text("niceId").notNull(), + isDefault: integer("isDefault", { mode: 'boolean' }).notNull().default(true), idpId: integer("idpId").references(() => idp.idpId, { onDelete: "set null" }), From 4d5f36466340bd1687b175650040d439547aea6e Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 13 Feb 2026 05:38:57 +0100 Subject: [PATCH 08/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20use=20the=20correct?= =?UTF-8?q?=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/queries.ts | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/lib/queries.ts b/src/lib/queries.ts index f0dfa811a..0aa04dcf1 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -4,7 +4,9 @@ import type { ListClientsResponse } from "@server/routers/client"; import type { ListDomainsResponse } from "@server/routers/domain"; import type { GetResourceWhitelistResponse, - ListResourceNamesResponse + ListResourceNamesResponse, + ListResourceRolesResponse, + ListResourceUsersResponse } from "@server/routers/resource"; import type { ListRolesResponse } from "@server/routers/role"; import type { ListSitesResponse } from "@server/routers/site"; @@ -113,7 +115,7 @@ export const orgQueries = { return res.data.data.clients; } }), - users: ({ orgId }: { orgId: string }) => + users: ({ orgId }: { orgId: string; }) => queryOptions({ queryKey: ["ORG", orgId, "USERS"] as const, queryFn: async ({ signal, meta }) => { @@ -124,7 +126,7 @@ export const orgQueries = { return res.data.data.users; } }), - roles: ({ orgId }: { orgId: string }) => + roles: ({ orgId }: { orgId: string; }) => queryOptions({ queryKey: ["ORG", orgId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { @@ -136,7 +138,7 @@ export const orgQueries = { } }), - sites: ({ orgId }: { orgId: string }) => + sites: ({ orgId }: { orgId: string; }) => queryOptions({ queryKey: ["ORG", orgId, "SITES"] as const, queryFn: async ({ signal, meta }) => { @@ -147,7 +149,7 @@ export const orgQueries = { } }), - domains: ({ orgId }: { orgId: string }) => + domains: ({ orgId }: { orgId: string; }) => queryOptions({ queryKey: ["ORG", orgId, "DOMAINS"] as const, queryFn: async ({ signal, meta }) => { @@ -169,7 +171,7 @@ export const orgQueries = { queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse<{ - idps: { idpId: number; name: string }[]; + idps: { idpId: number; name: string; }[]; }> >( build === "saas" || useOrgOnlyIdp @@ -234,28 +236,28 @@ export const logQueries = { }; export const resourceQueries = { - resourceUsers: ({ resourceId }: { resourceId: number }) => + resourceUsers: ({ resourceId }: { resourceId: number; }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "USERS"] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< - AxiosResponse + AxiosResponse >(`/resource/${resourceId}/users`, { signal }); return res.data.data.users; } }), - resourceRoles: ({ resourceId }: { resourceId: number }) => + resourceRoles: ({ resourceId }: { resourceId: number; }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< - AxiosResponse + AxiosResponse >(`/resource/${resourceId}/roles`, { signal }); return res.data.data.roles; } }), - siteResourceUsers: ({ siteResourceId }: { siteResourceId: number }) => + siteResourceUsers: ({ siteResourceId }: { siteResourceId: number; }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "USERS"] as const, queryFn: async ({ signal, meta }) => { @@ -265,7 +267,7 @@ export const resourceQueries = { return res.data.data.users; } }), - siteResourceRoles: ({ siteResourceId }: { siteResourceId: number }) => + siteResourceRoles: ({ siteResourceId }: { siteResourceId: number; }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { @@ -276,7 +278,7 @@ export const resourceQueries = { return res.data.data.roles; } }), - siteResourceClients: ({ siteResourceId }: { siteResourceId: number }) => + siteResourceClients: ({ siteResourceId }: { siteResourceId: number; }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "CLIENTS"] as const, queryFn: async ({ signal, meta }) => { @@ -287,7 +289,7 @@ export const resourceQueries = { return res.data.data.clients; } }), - resourceTargets: ({ resourceId }: { resourceId: number }) => + resourceTargets: ({ resourceId }: { resourceId: number; }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "TARGETS"] as const, queryFn: async ({ signal, meta }) => { @@ -298,7 +300,7 @@ export const resourceQueries = { return res.data.data.targets; } }), - resourceWhitelist: ({ resourceId }: { resourceId: number }) => + resourceWhitelist: ({ resourceId }: { resourceId: number; }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "WHITELISTS"] as const, queryFn: async ({ signal, meta }) => { @@ -371,7 +373,7 @@ export const approvalQueries = { } const res = await meta!.api.get< - AxiosResponse<{ approvals: ApprovalItem[] }> + AxiosResponse<{ approvals: ApprovalItem[]; }> >(`/org/${orgId}/approvals?${sp.toString()}`, { signal }); @@ -383,7 +385,7 @@ export const approvalQueries = { queryKey: ["APPROVALS", orgId, "COUNT", "pending"] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< - AxiosResponse<{ count: number }> + AxiosResponse<{ count: number; }> >(`/org/${orgId}/approvals/count?approvalState=pending`, { signal }); From 47fe497ca1d0ff64e743ac074cf9ef639b32d987 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 13 Feb 2026 05:39:16 +0100 Subject: [PATCH 09/89] =?UTF-8?q?=F0=9F=9A=A7=20add=20sidebar=20item=20for?= =?UTF-8?q?=20policies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + src/app/navigation.tsx | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 68f9640b2..d311976a6 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1231,6 +1231,7 @@ "sidebarResources": "Resources", "sidebarProxyResources": "Public", "sidebarClientResources": "Private", + "sidebarResourcePolicies": "Policies", "sidebarAccessControl": "Access Control", "sidebarLogsAndAnalytics": "Logs & Analytics", "sidebarUsers": "Users", diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 7df4364a5..85d7d010a 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -17,6 +17,7 @@ import { ScanEye, // Added from 'dev' branch Server, Settings, + ShieldIcon, SquareMousePointer, TicketCheck, User, @@ -62,7 +63,18 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [ title: "sidebarClientResources", href: "/{orgId}/settings/resources/client", icon: - } + }, + ...(build !== "oss" + ? [ + { + title: "sidebarResourcePolicies", + href: "/{orgId}/settings/resources/policies", + icon: ( + + ) + } + ] + : []) ] }, { @@ -86,7 +98,7 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [ href: "/{orgId}/settings/domains", icon: }, - ...(build == "saas" + ...(build === "saas" ? [ { title: "sidebarRemoteExitNodes", From 8d682ed9ad78fdaa098839957db77f3b2c6ff9d3 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 13 Feb 2026 05:39:35 +0100 Subject: [PATCH 10/89] =?UTF-8?q?=F0=9F=9A=A7=20list=20policies=20endpoint?= =?UTF-8?q?=20+=20list=20policies=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routers/resource/listResourcePolicies.ts | 175 ++++++++++++++++++ .../settings/resources/policies/page.tsx | 14 ++ 2 files changed, 189 insertions(+) create mode 100644 server/private/routers/resource/listResourcePolicies.ts create mode 100644 src/app/[orgId]/settings/resources/policies/page.tsx diff --git a/server/private/routers/resource/listResourcePolicies.ts b/server/private/routers/resource/listResourcePolicies.ts new file mode 100644 index 000000000..57ab0d4af --- /dev/null +++ b/server/private/routers/resource/listResourcePolicies.ts @@ -0,0 +1,175 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + resourceHeaderAuth, + resourceHeaderAuthExtendedCompatibility, + resourcePolicies +} from "@server/db"; +import { + resources, + userResources, + roleResources, + resourcePassword, + resourcePincode, + targets, + targetHealthCheck +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { sql, eq, or, inArray, and, count } from "drizzle-orm"; +import logger from "@server/logger"; +import { fromZodError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const listResourcePoliciesParamsSchema = z.strictObject({ + orgId: z.string() +}); + +const listResourcePoliciesSchema = z.object({ + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() + .optional() + .catch(20) + .default(20), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) + .optional() + .catch(1) + .default(1), + query: z.string().optional(), +}); + +function queryResourcePoliciesBase() { + return db + .select({ + resourcePolicyId: resourcePolicies.resourcePolicyId, + name: resourcePolicies.name, + niceId: resourcePolicies.niceId, + passwordId: resourcePassword.passwordId, + sso: resourcePolicies.sso, + pincodeId: resourcePincode.pincodeId, + whitelist: resourcePolicies.emailWhitelistEnabled, + headerAuthId: resourceHeaderAuth.headerAuthId, + headerAuthExtendedCompatibilityId: + resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId + }) + .from(resourcePolicies) + .leftJoin( + resourcePassword, + eq(resourcePassword.resourcePolicyId, resourcePolicies.resourcePolicyId) + ) + .leftJoin( + resourcePincode, + eq(resourcePincode.resourcePolicyId, resourcePolicies.resourcePolicyId) + ) + .leftJoin( + resourceHeaderAuth, + eq(resourceHeaderAuth.resourcePolicyId, resourcePolicies.resourcePolicyId) + ) + .leftJoin( + resourceHeaderAuthExtendedCompatibility, + eq( + resourceHeaderAuthExtendedCompatibility.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ); + +} + +// TODO: replaced with `PaginatedResponse` when paginated table PR is merged +export type ListResourcesResponse = { + policies: Awaited>; + total: number; pageSize: number; page: number; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/resource-policies", + description: "List resource policies for an organization.", + tags: [OpenAPITags.Org, OpenAPITags.Resource], + request: { + params: z.object({ + orgId: z.string() + }), + query: listResourcePoliciesSchema + }, + responses: {} +}); + + + +export async function listResourcePolicies( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listResourcePoliciesSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedQuery.error) + ) + ); + } + const { page, pageSize, query, } = + parsedQuery.data; + + const parsedParams = listResourcePoliciesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedParams.error) + ) + ); + } + + const orgId = + parsedParams.data.orgId || + req.userOrg?.orgId || + req.apiKeyOrg?.orgId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (req.user && orgId && orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/resources/policies/page.tsx b/src/app/[orgId]/settings/resources/policies/page.tsx new file mode 100644 index 000000000..59e7120f9 --- /dev/null +++ b/src/app/[orgId]/settings/resources/policies/page.tsx @@ -0,0 +1,14 @@ +import { getTranslations } from "next-intl/server"; + +export interface ResourcePoliciesPageProps { + params: Promise<{ orgId: string }>; + searchParams: Promise<{ view?: string }>; +} + +export default async function ResourcePoliciesPage( + props: ResourcePoliciesPageProps +) { + const params = await props.params; + const t = await getTranslations(); + return <>; +} From 2c3e768867542ea2e2bc40a4b3ec262dfa363486 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 13 Feb 2026 05:54:45 +0100 Subject: [PATCH 11/89] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20list=20resource=20e?= =?UTF-8?q?ndpoints=20finished?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/auth/actions.ts | 6 +- server/private/routers/external.ts | 13 +++ server/private/routers/resource/index.ts | 1 + .../routers/resource/listResourcePolicies.ts | 82 ++++++++++++++++++- 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 094437f43..01748b6b9 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -131,7 +131,11 @@ export enum ActionsEnum { viewLogs = "viewLogs", exportLogs = "exportLogs", listApprovals = "listApprovals", - updateApprovals = "updateApprovals" + updateApprovals = "updateApprovals", + listResourcePolicies = "listResourcePolicies", + createResourcePolicies = "createResourcePolicies", + updateResourcePolicies = "updateResourcePolicies", + deleteResourcePolicies = "deleteResourcePolicies", } export async function checkUserActionPermission( diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index dae10a954..31724e8cd 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -25,6 +25,7 @@ import * as logs from "#private/routers/auditLogs"; import * as misc from "#private/routers/misc"; import * as reKey from "#private/routers/re-key"; import * as approval from "#private/routers/approvals"; +import * as resource from "#private/routers/resource"; import { verifyOrgAccess, @@ -340,6 +341,18 @@ authenticated.get( approval.countApprovals ); +authenticated.get( + "/org/:orgId/resource-policies", + verifyValidLicense, + // verifyValidSubscription(tierMatrix.loginPageDomain), // todo: use the correct subscription ? + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.listResourcePolicies), + logActionAudit(ActionsEnum.listResourcePolicies), + resource.listResourcePolicies +); + + authenticated.put( "/org/:orgId/approvals/:approvalId", verifyValidLicense, diff --git a/server/private/routers/resource/index.ts b/server/private/routers/resource/index.ts index f82b55524..4bae8e982 100644 --- a/server/private/routers/resource/index.ts +++ b/server/private/routers/resource/index.ts @@ -12,3 +12,4 @@ */ export * from "./getMaintenanceInfo"; +export * from "./listResourcePolicies"; diff --git a/server/private/routers/resource/listResourcePolicies.ts b/server/private/routers/resource/listResourcePolicies.ts index 57ab0d4af..cc280f235 100644 --- a/server/private/routers/resource/listResourcePolicies.ts +++ b/server/private/routers/resource/listResourcePolicies.ts @@ -32,7 +32,7 @@ import { import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql, eq, or, inArray, and, count } from "drizzle-orm"; +import { sql, eq, or, inArray, and, count, ilike, asc } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; @@ -97,9 +97,9 @@ function queryResourcePoliciesBase() { } // TODO: replaced with `PaginatedResponse` when paginated table PR is merged -export type ListResourcesResponse = { +export type ListResourcePoliciesResponse = { policies: Awaited>; - total: number; pageSize: number; page: number; + pagination: { total: number; pageSize: number; page: number; }; }; registry.registerPath({ @@ -166,6 +166,82 @@ export async function listResourcePolicies( ); } + let accessibleResourcePolicies: Array<{ resourcePolicyId: number; }>; + if (req.user) { + accessibleResourcePolicies = await db + .select({ + resourcePolicyId: sql`COALESCE(${userResources.resourcePolicyId}, ${roleResources.resourcePolicyId})` + }) + .from(userResources) + .fullJoin( + roleResources, + eq(userResources.resourcePolicyId, roleResources.resourcePolicyId) + ) + .where( + or( + eq(userResources.userId, req.user!.userId), + eq(roleResources.roleId, req.userOrgRoleId!) + ) + ); + } else { + accessibleResourcePolicies = await db + .select({ + resourcePolicyId: resourcePolicies.resourcePolicyId + }) + .from(resourcePolicies) + .where(eq(resourcePolicies.orgId, orgId)); + } + + const accessibleResourceIds = accessibleResourcePolicies.map( + (resource) => resource.resourcePolicyId + ); + + const conditions = [ + and( + inArray(resourcePolicies.resourcePolicyId, accessibleResourceIds), + eq(resourcePolicies.orgId, orgId) + ) + ]; + + if (query) { + conditions.push( + or( + ilike(resourcePolicies.name, "%" + query + "%"), + ilike(resourcePolicies.niceId, "%" + query + "%"), + ) + ); + } + + const baseQuery = queryResourcePoliciesBase() + .where(and(...conditions)); + + // we need to add `as` so that drizzle filters the result as a subquery + const countQuery = db.$count(baseQuery.as("filtered_policies")); + + const [rows, totalCount] = await Promise.all([ + baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy(asc(resourcePolicies.resourcePolicyId)), + countQuery + ]); + + return response(res, { + data: { + policies: rows, + pagination: { + total: totalCount, + pageSize, + page + } + }, + success: true, + error: false, + message: "Resources retrieved successfully", + status: HttpCode.OK + }); + + } catch (error) { logger.error(error); return next( From 230516347460c5902c52dcaef409d09481f649b6 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 14 Feb 2026 03:24:01 +0100 Subject: [PATCH 12/89] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 4 + .../routers/resource/listResourcePolicies.ts | 71 ++----- server/routers/resource/types.ts | 9 + .../settings/general/auth-page/page.tsx | 1 - src/app/[orgId]/settings/layout.tsx | 9 +- .../settings/resources/policies/page.tsx | 61 +++++- src/components/AuthPageBrandingForm.tsx | 28 ++- src/components/ResourcePoliciesTable.tsx | 182 ++++++++++++++++++ 8 files changed, 290 insertions(+), 75 deletions(-) create mode 100644 src/components/ResourcePoliciesTable.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 4fec9cf6c..84dad51a2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -166,6 +166,10 @@ "resourcesSearch": "Search resources...", "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", + "resourcePoliciesTitle": "Manage Resource Policies", + "resourcePoliciesDescription": "Create and manage authentication policies to control access to your resources", + "resourcePoliciesSearch": "Search policies...", + "resourcePoliciesAdd": "Add Policy", "authentication": "Authentication", "protected": "Protected", "notProtected": "Not Protected", diff --git a/server/private/routers/resource/listResourcePolicies.ts b/server/private/routers/resource/listResourcePolicies.ts index cc280f235..940a4b781 100644 --- a/server/private/routers/resource/listResourcePolicies.ts +++ b/server/private/routers/resource/listResourcePolicies.ts @@ -11,7 +11,6 @@ * This file is not licensed under the AGPLv3. */ - import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { @@ -36,6 +35,8 @@ import { sql, eq, or, inArray, and, count, ilike, asc } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import type { PaginatedResponse } from "@server/types/Pagination"; +import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; const listResourcePoliciesParamsSchema = z.strictObject({ orgId: z.string() @@ -56,7 +57,7 @@ const listResourcePoliciesSchema = z.object({ .optional() .catch(1) .default(1), - query: z.string().optional(), + query: z.string().optional() }); function queryResourcePoliciesBase() { @@ -65,43 +66,11 @@ function queryResourcePoliciesBase() { resourcePolicyId: resourcePolicies.resourcePolicyId, name: resourcePolicies.name, niceId: resourcePolicies.niceId, - passwordId: resourcePassword.passwordId, - sso: resourcePolicies.sso, - pincodeId: resourcePincode.pincodeId, - whitelist: resourcePolicies.emailWhitelistEnabled, - headerAuthId: resourceHeaderAuth.headerAuthId, - headerAuthExtendedCompatibilityId: - resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId + orgId: resourcePolicies.orgId }) - .from(resourcePolicies) - .leftJoin( - resourcePassword, - eq(resourcePassword.resourcePolicyId, resourcePolicies.resourcePolicyId) - ) - .leftJoin( - resourcePincode, - eq(resourcePincode.resourcePolicyId, resourcePolicies.resourcePolicyId) - ) - .leftJoin( - resourceHeaderAuth, - eq(resourceHeaderAuth.resourcePolicyId, resourcePolicies.resourcePolicyId) - ) - .leftJoin( - resourceHeaderAuthExtendedCompatibility, - eq( - resourceHeaderAuthExtendedCompatibility.resourcePolicyId, - resourcePolicies.resourcePolicyId - ) - ); - + .from(resourcePolicies); } -// TODO: replaced with `PaginatedResponse` when paginated table PR is merged -export type ListResourcePoliciesResponse = { - policies: Awaited>; - pagination: { total: number; pageSize: number; page: number; }; -}; - registry.registerPath({ method: "get", path: "/org/{orgId}/resource-policies", @@ -116,8 +85,6 @@ registry.registerPath({ responses: {} }); - - export async function listResourcePolicies( req: Request, res: Response, @@ -133,10 +100,11 @@ export async function listResourcePolicies( ) ); } - const { page, pageSize, query, } = - parsedQuery.data; + const { page, pageSize, query } = parsedQuery.data; - const parsedParams = listResourcePoliciesParamsSchema.safeParse(req.params); + const parsedParams = listResourcePoliciesParamsSchema.safeParse( + req.params + ); if (!parsedParams.success) { return next( createHttpError( @@ -166,7 +134,7 @@ export async function listResourcePolicies( ); } - let accessibleResourcePolicies: Array<{ resourcePolicyId: number; }>; + let accessibleResourcePolicies: Array<{ resourcePolicyId: number }>; if (req.user) { accessibleResourcePolicies = await db .select({ @@ -175,7 +143,10 @@ export async function listResourcePolicies( .from(userResources) .fullJoin( roleResources, - eq(userResources.resourcePolicyId, roleResources.resourcePolicyId) + eq( + userResources.resourcePolicyId, + roleResources.resourcePolicyId + ) ) .where( or( @@ -198,7 +169,10 @@ export async function listResourcePolicies( const conditions = [ and( - inArray(resourcePolicies.resourcePolicyId, accessibleResourceIds), + inArray( + resourcePolicies.resourcePolicyId, + accessibleResourceIds + ), eq(resourcePolicies.orgId, orgId) ) ]; @@ -207,13 +181,12 @@ export async function listResourcePolicies( conditions.push( or( ilike(resourcePolicies.name, "%" + query + "%"), - ilike(resourcePolicies.niceId, "%" + query + "%"), + ilike(resourcePolicies.niceId, "%" + query + "%") ) ); } - const baseQuery = queryResourcePoliciesBase() - .where(and(...conditions)); + const baseQuery = queryResourcePoliciesBase().where(and(...conditions)); // we need to add `as` so that drizzle filters the result as a subquery const countQuery = db.$count(baseQuery.as("filtered_policies")); @@ -240,12 +213,10 @@ export async function listResourcePolicies( message: "Resources retrieved successfully", status: HttpCode.OK }); - - } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/routers/resource/types.ts b/server/routers/resource/types.ts index 9dcdcd086..223154a01 100644 --- a/server/routers/resource/types.ts +++ b/server/routers/resource/types.ts @@ -1,3 +1,6 @@ +import type { ResourcePolicy } from "@server/db"; +import type { PaginatedResponse } from "@server/types/Pagination"; + export type GetMaintenanceInfoResponse = { resourceId: number; name: string; @@ -8,3 +11,9 @@ export type GetMaintenanceInfoResponse = { maintenanceMessage: string | null; maintenanceEstimatedTime: string | null; }; + +export type ListResourcePoliciesResponse = PaginatedResponse<{ + policies: Array< + Pick + >; +}>; diff --git a/src/app/[orgId]/settings/general/auth-page/page.tsx b/src/app/[orgId]/settings/general/auth-page/page.tsx index 0bd482864..f245c8f86 100644 --- a/src/app/[orgId]/settings/general/auth-page/page.tsx +++ b/src/app/[orgId]/settings/general/auth-page/page.tsx @@ -11,7 +11,6 @@ import { GetLoginPageResponse } from "@server/routers/loginPage/types"; import { AxiosResponse } from "axios"; -import { redirect } from "next/navigation"; export interface AuthPageProps { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 34ed3ac2f..310d36ca0 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -13,6 +13,7 @@ import { Layout } from "@app/components/Layout"; import { getTranslations } from "next-intl/server"; import { pullEnv } from "@app/lib/pullEnv"; import { orgNavSections } from "@app/app/navigation"; +import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser"; export const dynamic = "force-dynamic"; @@ -48,13 +49,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const t = await getTranslations(); try { - const getOrgUser = cache(() => - internal.get>( - `/org/${params.orgId}/user/${user.userId}`, - cookie - ) - ); - const orgUser = await getOrgUser(); + const orgUser = await getCachedOrgUser(params.orgId, user.userId); if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) { throw new Error(t("userErrorNotAdminOrOwner")); diff --git a/src/app/[orgId]/settings/resources/policies/page.tsx b/src/app/[orgId]/settings/resources/policies/page.tsx index 59e7120f9..e641696ef 100644 --- a/src/app/[orgId]/settings/resources/policies/page.tsx +++ b/src/app/[orgId]/settings/resources/policies/page.tsx @@ -1,8 +1,18 @@ +import { ResourcePoliciesTable } from "@app/components/ResourcePoliciesTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import OrgProvider from "@app/providers/OrgProvider"; +import type { GetOrgResponse } from "@server/routers/org"; +import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; +import type { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; export interface ResourcePoliciesPageProps { params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; + searchParams: Promise>; } export default async function ResourcePoliciesPage( @@ -10,5 +20,52 @@ export default async function ResourcePoliciesPage( ) { const params = await props.params; const t = await getTranslations(); - return <>; + const searchParams = new URLSearchParams(await props.searchParams); + + let org: GetOrgResponse | null = null; + try { + const res = await getCachedOrg(params.orgId); + org = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/resources`); + } + + let policies: ListResourcePoliciesResponse["policies"] = []; + let pagination: ListResourcePoliciesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/resource-policies?${searchParams.toString()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + policies = responseData.policies; + pagination = responseData.pagination; + } catch (e) {} + + return ( + <> + + + + + + + ); } diff --git a/src/components/AuthPageBrandingForm.tsx b/src/components/AuthPageBrandingForm.tsx index a19980627..f3c1da524 100644 --- a/src/components/AuthPageBrandingForm.tsx +++ b/src/components/AuthPageBrandingForm.tsx @@ -1,18 +1,18 @@ "use client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { startTransition, useActionState, useState } from "react"; -import { useForm } from "react-hook-form"; -import z from "zod"; import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; +import { useActionState } from "react"; +import { useForm } from "react-hook-form"; +import z from "zod"; import { SettingsSection, SettingsSectionBody, @@ -21,21 +21,19 @@ import { SettingsSectionHeader, SettingsSectionTitle } from "./Settings"; -import { useTranslations } from "next-intl"; -import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types"; -import { Input } from "./ui/input"; -import { ExternalLink, InfoIcon, XIcon } from "lucide-react"; -import { Button } from "./ui/button"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useRouter } from "next/navigation"; -import { toast } from "@app/hooks/useToast"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; import { build } from "@server/build"; -import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; -import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types"; +import { XIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; export type AuthPageCustomizationProps = { orgId: string; diff --git a/src/components/ResourcePoliciesTable.tsx b/src/components/ResourcePoliciesTable.tsx new file mode 100644 index 000000000..0d348b2a3 --- /dev/null +++ b/src/components/ResourcePoliciesTable.tsx @@ -0,0 +1,182 @@ +"use client"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient } from "@app/lib/api"; +import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; +import type { PaginationState } from "@tanstack/react-table"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; +import type { ExtendedColumnDef } from "./ui/data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "./ui/dropdown-menu"; +import { Button } from "./ui/button"; +import { MoreHorizontal, ArrowRight } from "lucide-react"; +import Link from "next/link"; +import { ControlledDataTable } from "./ui/controlled-data-table"; +import { useDebouncedCallback } from "use-debounce"; + +type ResourcePolicyRow = ListResourcePoliciesResponse["policies"][number]; + +export type ResourcePoliciesTableProps = { + policies: Array; + orgId: string; + pagination: PaginationState; + rowCount: number; +}; + +export function ResourcePoliciesTable({ + policies, + orgId, + pagination, + rowCount +}: ResourcePoliciesTableProps) { + const router = useRouter(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + const t = useTranslations(); + + const { env } = useEnvContext(); + + const api = createApiClient({ env }); + + const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); + + const refreshData = () => { + startTransition(() => { + try { + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + }; + + const proxyColumns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: t("name"), + header: () => {t("name")} + }, + { + id: "niceId", + accessorKey: "nice", + friendlyName: t("identifier"), + enableHiding: true, + header: () => {t("identifier")}, + cell: ({ row }) => { + return {row.original.niceId || "-"}; + } + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const policyRow = row.original; + return ( +
+ + + + + + + + {t("viewSettings")} + + + { + // setSelectedResource(resourceRow); + // setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + + + +
+ ); + } + } + ]; + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); + + return ( + <> + + startNavigation(() => + router.push( + `/${orgId}/settings/resources/policies/create` + ) + ) + } + addButtonText={t("resourcePoliciesAdd")} + onRefresh={refreshData} + isRefreshing={isRefreshing || isFiltering} + isNavigatingToAddPage={isNavigatingToAddPage} + enableColumnVisibility + columnVisibility={{ niceId: false }} + stickyLeftColumn="name" + stickyRightColumn="actions" + /> + + ); +} From 805d82b8d94493f6c4ffcb394e001e631c641411 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 14 Feb 2026 04:59:35 +0100 Subject: [PATCH 13/89] =?UTF-8?q?=E2=9C=A8=20policies=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + .../routers/resource/listResourcePolicies.ts | 42 ++++++++----------- server/routers/resource/types.ts | 5 ++- src/components/ResourcePoliciesTable.tsx | 21 +++++++++- 4 files changed, 42 insertions(+), 27 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 84dad51a2..d50073f1f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -170,6 +170,7 @@ "resourcePoliciesDescription": "Create and manage authentication policies to control access to your resources", "resourcePoliciesSearch": "Search policies...", "resourcePoliciesAdd": "Add Policy", + "resourcePoliciesDefaultBadgeText": "Default policy", "authentication": "Authentication", "protected": "Protected", "notProtected": "Not Protected", diff --git a/server/private/routers/resource/listResourcePolicies.ts b/server/private/routers/resource/listResourcePolicies.ts index 940a4b781..0f2089bbc 100644 --- a/server/private/routers/resource/listResourcePolicies.ts +++ b/server/private/routers/resource/listResourcePolicies.ts @@ -11,32 +11,17 @@ * This file is not licensed under the AGPLv3. */ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { - db, - resourceHeaderAuth, - resourceHeaderAuthExtendedCompatibility, - resourcePolicies -} from "@server/db"; -import { - resources, - userResources, - roleResources, - resourcePassword, - resourcePincode, - targets, - targetHealthCheck -} from "@server/db"; +import { db, resourcePolicies, roleResources, userResources } from "@server/db"; import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import { sql, eq, or, inArray, and, count, ilike, asc } from "drizzle-orm"; import logger from "@server/logger"; -import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import type { PaginatedResponse } from "@server/types/Pagination"; import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; +import HttpCode from "@server/types/HttpCode"; +import { and, asc, eq, inArray, like, or, sql } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromZodError } from "zod-validation-error"; const listResourcePoliciesParamsSchema = z.strictObject({ orgId: z.string() @@ -66,7 +51,8 @@ function queryResourcePoliciesBase() { resourcePolicyId: resourcePolicies.resourcePolicyId, name: resourcePolicies.name, niceId: resourcePolicies.niceId, - orgId: resourcePolicies.orgId + orgId: resourcePolicies.orgId, + isDefault: resourcePolicies.isDefault }) .from(resourcePolicies); } @@ -180,8 +166,14 @@ export async function listResourcePolicies( if (query) { conditions.push( or( - ilike(resourcePolicies.name, "%" + query + "%"), - ilike(resourcePolicies.niceId, "%" + query + "%") + like( + sql`LOWER(${resourcePolicies.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${resourcePolicies.niceId})`, + "%" + query.toLowerCase() + "%" + ) ) ); } diff --git a/server/routers/resource/types.ts b/server/routers/resource/types.ts index 223154a01..6e0ea3d50 100644 --- a/server/routers/resource/types.ts +++ b/server/routers/resource/types.ts @@ -14,6 +14,9 @@ export type GetMaintenanceInfoResponse = { export type ListResourcePoliciesResponse = PaginatedResponse<{ policies: Array< - Pick + Pick< + ResourcePolicy, + "resourcePolicyId" | "niceId" | "name" | "orgId" | "isDefault" + > >; }>; diff --git a/src/components/ResourcePoliciesTable.tsx b/src/components/ResourcePoliciesTable.tsx index 0d348b2a3..1473f72d4 100644 --- a/src/components/ResourcePoliciesTable.tsx +++ b/src/components/ResourcePoliciesTable.tsx @@ -20,6 +20,7 @@ import { MoreHorizontal, ArrowRight } from "lucide-react"; import Link from "next/link"; import { ControlledDataTable } from "./ui/controlled-data-table"; import { useDebouncedCallback } from "use-debounce"; +import { Badge } from "./ui/badge"; type ResourcePolicyRow = ListResourcePoliciesResponse["policies"][number]; @@ -70,7 +71,25 @@ export function ResourcePoliciesTable({ accessorKey: "name", enableHiding: false, friendlyName: t("name"), - header: () => {t("name")} + header: () => {t("name")}, + cell({ row }) { + const r = row.original; + return ( +
+ {r.name} + {r.isDefault && ( + <> + + {t("resourcePoliciesDefaultBadgeText")} + + + )} +
+ ); + } }, { id: "niceId", From 801f6fb6612e2860ecbe172deb822cd59e462013 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 14 Feb 2026 05:03:40 +0100 Subject: [PATCH 14/89] =?UTF-8?q?=F0=9F=9A=9A=20move=20policies=20page=20t?= =?UTF-8?q?o=20`(private)`=20folder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[orgId]/settings/{ => (private)}/resources/policies/page.tsx | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/app/[orgId]/settings/{ => (private)}/resources/policies/page.tsx (100%) diff --git a/src/app/[orgId]/settings/resources/policies/page.tsx b/src/app/[orgId]/settings/(private)/resources/policies/page.tsx similarity index 100% rename from src/app/[orgId]/settings/resources/policies/page.tsx rename to src/app/[orgId]/settings/(private)/resources/policies/page.tsx From 7177ab7f770b1992b23a79736f7a5666e9edfb64 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 14 Feb 2026 05:08:41 +0100 Subject: [PATCH 15/89] =?UTF-8?q?=F0=9F=9A=A7=20create=20resource=20policy?= =?UTF-8?q?=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 ++ .../resources/policies/create/page.tsx | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 src/app/[orgId]/settings/(private)/resources/policies/create/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index d50073f1f..ffd28a518 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -171,6 +171,8 @@ "resourcePoliciesSearch": "Search policies...", "resourcePoliciesAdd": "Add Policy", "resourcePoliciesDefaultBadgeText": "Default policy", + "resourcePoliciesCreate": "Create Resource Policy", + "resourcePoliciesCreateDescription": "Follow the steps below to create a new policy", "authentication": "Authentication", "protected": "Protected", "notProtected": "Not Protected", diff --git a/src/app/[orgId]/settings/(private)/resources/policies/create/page.tsx b/src/app/[orgId]/settings/(private)/resources/policies/create/page.tsx new file mode 100644 index 000000000..02afa06cd --- /dev/null +++ b/src/app/[orgId]/settings/(private)/resources/policies/create/page.tsx @@ -0,0 +1,32 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import type { GetOrgResponse } from "@server/routers/org"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; + +export interface CreateResourcePolicyPageProps { + params: Promise<{ orgId: string }>; +} + +export default async function CreateResourcePolicyPage( + props: CreateResourcePolicyPageProps +) { + const params = await props.params; + const t = await getTranslations(); + + let org: GetOrgResponse | null = null; + try { + const res = await getCachedOrg(params.orgId); + org = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/resources`); + } + return ( + <> + + + ); +} From e409a34a09f3bd81674323f64f5b52c684c4b000 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 18 Feb 2026 05:08:27 +0100 Subject: [PATCH 16/89] =?UTF-8?q?=F0=9F=9A=A7=20create=20policy=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 4 + .../resources/policies/create/page.tsx | 24 +- src/components/CreatePolicyForm.tsx | 620 ++++++++++++++++++ 3 files changed, 644 insertions(+), 4 deletions(-) create mode 100644 src/components/CreatePolicyForm.tsx diff --git a/messages/en-US.json b/messages/en-US.json index ffd28a518..58a772967 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -173,6 +173,10 @@ "resourcePoliciesDefaultBadgeText": "Default policy", "resourcePoliciesCreate": "Create Resource Policy", "resourcePoliciesCreateDescription": "Follow the steps below to create a new policy", + "resourcePolicyName": "Policy Name", + "resourcePolicyNameDescription": "Give this policy a name to identify it across your resources", + "resourcePolicyNamePlaceholder": "e.g. Internal Access Policy", + "policiesSeeAll": "See All Policies", "authentication": "Authentication", "protected": "Protected", "notProtected": "Not Protected", diff --git a/src/app/[orgId]/settings/(private)/resources/policies/create/page.tsx b/src/app/[orgId]/settings/(private)/resources/policies/create/page.tsx index 02afa06cd..e43ef39ee 100644 --- a/src/app/[orgId]/settings/(private)/resources/policies/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/resources/policies/create/page.tsx @@ -1,7 +1,11 @@ +import { CreatePolicyForm } from "@app/components/CreatePolicyForm"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { Button } from "@app/components/ui/button"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import OrgProvider from "@app/providers/OrgProvider"; import type { GetOrgResponse } from "@server/routers/org"; import { getTranslations } from "next-intl/server"; +import Link from "next/link"; import { redirect } from "next/navigation"; export interface CreateResourcePolicyPageProps { @@ -23,10 +27,22 @@ export default async function CreateResourcePolicyPage( } return ( <> - +
+ + + +
+ + + + ); } diff --git a/src/components/CreatePolicyForm.tsx b/src/components/CreatePolicyForm.tsx new file mode 100644 index 000000000..b1cb49a01 --- /dev/null +++ b/src/components/CreatePolicyForm.tsx @@ -0,0 +1,620 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { createApiClient } from "@app/lib/api"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { orgQueries } from "@app/lib/queries"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { build } from "@server/build"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { UserType } from "@server/types/UserTypes"; +import { useQuery } from "@tanstack/react-query"; +import { Binary, Bot, InfoIcon, Key } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useActionState, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import z from "zod"; + +const createPolicySchema = z.object({ + name: z.string().min(1).max(255), + sso: z.boolean().default(true), + skipToIdpId: z.number().nullable().optional(), + emailWhitelistEnabled: z.boolean().default(false), + roles: z.array( + z.object({ + id: z.string(), + text: z.string() + }) + ), + users: z.array( + z.object({ + id: z.string(), + text: z.string() + }) + ), + emails: z.array( + z.object({ + id: z.string(), + text: z.string() + }) + ) +}); + +export type CreatePolicyFormProps = {}; + +export function CreatePolicyForm({}: CreatePolicyFormProps) { + const { org } = useOrgContext(); + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const [, formAction, isSubmitting] = useActionState(onSubmit, null); + const router = useRouter(); + const { isPaidUser } = usePaidStatus(); + + const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery( + orgQueries.roles({ + orgId: org.org.orgId + }) + ); + const { data: orgUsers = [], isLoading: isLoadingOrgUsers } = useQuery( + orgQueries.users({ + orgId: org.org.orgId + }) + ); + const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery( + orgQueries.identityProviders({ + orgId: org.org.orgId, + useOrgOnlyIdp: env.app.identityProviderMode === "org" + }) + ); + + const form = useForm({ + resolver: zodResolver(createPolicySchema), + defaultValues: { + name: "", + sso: true, + skipToIdpId: null, + emailWhitelistEnabled: false, + roles: [], + users: [], + emails: [] + } + }); + + const [ssoEnabled, setSsoEnabled] = useState(true); + const [whitelistEnabled, setWhitelistEnabled] = useState(false); + const [selectedIdpId, setSelectedIdpId] = useState(null); + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< + number | null + >(null); + const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< + number | null + >(null); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + + async function onSubmit() { + // ... + } + + const allRoles = useMemo(() => { + return orgRoles + .map((role) => ({ + id: role.roleId.toString(), + text: role.name + })) + .filter((role) => role.text !== "Admin"); + }, [orgRoles]); + + const allUsers = useMemo(() => { + return orgUsers.map((user) => ({ + id: user.id.toString(), + text: `${getUserDisplayName({ + email: user.email, + username: user.username + })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` + })); + }, [orgUsers]); + + const allIdps = useMemo(() => { + if (build === "saas") { + if (isPaidUser(tierMatrix.orgOidc)) { + return orgIdps.map((idp) => ({ + id: idp.idpId, + text: idp.name + })); + } + } else { + return orgIdps.map((idp) => ({ + id: idp.idpId, + text: idp.name + })); + } + return []; + }, [orgIdps]); + + const pageLoading = + isLoadingOrgRoles || isLoadingOrgUsers || isLoadingOrgIdps; + + if (pageLoading) { + return <>; + } + + return ( +
+ + + {/* Name */} + + + + {t("resourcePolicyName")} + + + {t("resourcePolicyNameDescription")} + + + + + ( + + {t("name")} + + + + + + )} + /> + + + + + {/* Users & Roles */} + + + + {t("resourceUsersRoles")} + + + {t("resourceUsersRolesDescription")} + + + + + { + setSsoEnabled(val); + form.setValue("sso", val); + }} + /> + + {ssoEnabled && ( + <> + ( + + + {t("roles")} + + + { + form.setValue( + "roles", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allRoles + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + {t( + "resourceRoleDescription" + )} + + + )} + /> + ( + + + {t("users")} + + + { + form.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allUsers + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + + )} + + {ssoEnabled && allIdps.length > 0 && ( +
+ + +

+ {t( + "defaultIdentityProviderDescription" + )} +

+
+ )} +
+
+
+ + {/* Auth Methods */} + + + + {t("resourceAuthMethods")} + + + {t("resourceAuthMethodsDescriptions")} + + + + +
+
+ + + {t("resourcePasswordProtection", { + status: t("disabled") + })} + +
+ +
+ +
+
+ + + {t("resourcePincodeProtection", { + status: t("disabled") + })} + +
+ +
+ +
+
+ + + {t( + "resourceHeaderAuthProtectionDisabled" + )} + +
+ +
+
+
+
+ + {/* OTP Email */} + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + {!env.email.emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t( + "otpEmailSmtpRequiredDescription" + )} + + + )} + { + setWhitelistEnabled(val); + form.setValue( + "emailWhitelistEnabled", + val + ); + }} + disabled={!env.email.emailEnabled} + /> + + {whitelistEnabled && env.email.emailEnabled && ( + ( + + + + + + {/* @ts-ignore */} + { + return z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: + t( + "otpEmailErrorInvalid" + ) + } + ) + ) + .safeParse(tag) + .success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder={t( + "otpEmailEnter" + )} + tags={ + form.getValues() + .emails + } + setTags={( + newEmails + ) => { + form.setValue( + "emails", + newEmails as [ + Tag, + ...Tag[] + ] + ); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t( + "otpEmailEnterDescription" + )} + + + )} + /> + )} + + + + + + +
+
+ + ); +} From ee21e1faa7d372e998ccec39a0df77ac163d16ee Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 18 Feb 2026 05:08:42 +0100 Subject: [PATCH 17/89] =?UTF-8?q?=F0=9F=9A=A7=20list=20authentication=20it?= =?UTF-8?q?ems=20from=20policy=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/auth/actions.ts | 4 + server/middlewares/index.ts | 1 + .../middlewares/verifyResourcePolicyAccess.ts | 125 +++++++++++++ server/routers/external.ts | 36 +++- server/routers/resource/index.ts | 4 + .../resource/listResourcePolicyRoles.ts | 80 +++++++++ .../resource/listResourcePolicyUsers.ts | 85 +++++++++ .../resource/setResourcePolicyRoles.ts | 165 ++++++++++++++++++ .../resource/setResourcePolicyUsers.ts | 124 +++++++++++++ 9 files changed, 623 insertions(+), 1 deletion(-) create mode 100644 server/middlewares/verifyResourcePolicyAccess.ts create mode 100644 server/routers/resource/listResourcePolicyRoles.ts create mode 100644 server/routers/resource/listResourcePolicyUsers.ts create mode 100644 server/routers/resource/setResourcePolicyRoles.ts create mode 100644 server/routers/resource/setResourcePolicyUsers.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 01748b6b9..9c94c6a6a 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -136,6 +136,10 @@ export enum ActionsEnum { createResourcePolicies = "createResourcePolicies", updateResourcePolicies = "updateResourcePolicies", deleteResourcePolicies = "deleteResourcePolicies", + listResourcePolicyRoles = "listResourcePolicyRoles", + setResourcePolicyRoles = "setResourcePolicyRoles", + listResourcePolicyUsers = "listResourcePolicyUsers", + setResourcePolicyUsers = "setResourcePolicyUsers", } export async function checkUserActionPermission( diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index 6437c90e2..9ea190113 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -30,3 +30,4 @@ export * from "./verifySiteResourceAccess"; export * from "./logActionAudit"; export * from "./verifyOlmAccess"; export * from "./verifyLimits"; +export * from "./verifyResourcePolicyAccess"; diff --git a/server/middlewares/verifyResourcePolicyAccess.ts b/server/middlewares/verifyResourcePolicyAccess.ts new file mode 100644 index 000000000..83eb69d7f --- /dev/null +++ b/server/middlewares/verifyResourcePolicyAccess.ts @@ -0,0 +1,125 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { resourcePolicies, userOrgs } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; + +export async function verifyResourcePolicyAccess( + req: Request, + res: Response, + next: NextFunction +) { + const userId = req.user!.userId; + const resourcePolicyIdStr = + req.params?.resourcePolicyId || + req.body?.resourcePolicyId || + req.query?.resourcePolicyId; + const niceId = + req.params?.niceId || req.body?.niceId || req.query?.niceId; + const orgId = + req.params?.orgId || req.body?.orgId || req.query?.orgId; + + try { + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + let policy: typeof resourcePolicies.$inferSelect | null = null; + + if (orgId && niceId) { + const [policyRes] = await db + .select() + .from(resourcePolicies) + .where( + and( + eq(resourcePolicies.niceId, niceId), + eq(resourcePolicies.orgId, orgId) + ) + ) + .limit(1); + policy = policyRes ?? null; + } else { + const resourcePolicyId = parseInt(resourcePolicyIdStr); + if (isNaN(resourcePolicyId)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid resource policy ID" + ) + ); + } + const [policyRes] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .limit(1); + policy = policyRes ?? null; + } + + if (!policy) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource policy with ID ${resourcePolicyIdStr ?? niceId} not found` + ) + ); + } + + if (!req.userOrg) { + const userOrgRes = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, policy.orgId) + ) + ) + .limit(1); + req.userOrg = userOrgRes[0]; + } + + if (!req.userOrg || req.userOrg.orgId !== policy.orgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + } + + req.userOrgRoleId = req.userOrg.roleId; + req.userOrgId = policy.orgId; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying resource policy access" + ) + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 52aaa81e9..c69fdacc5 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -42,7 +42,8 @@ import { verifyUserIsOrgOwner, verifySiteResourceAccess, verifyOlmAccess, - verifyLimits + verifyLimits, + verifyResourcePolicyAccess } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; @@ -676,6 +677,39 @@ authenticated.post( resource.setResourceUsers ); +authenticated.get( + "/resource-policy/:resourcePolicyId/roles", + verifyResourcePolicyAccess, + verifyUserHasAction(ActionsEnum.listResourcePolicyRoles), + resource.listResourcePolicyRoles +); + +authenticated.get( + "/resource-policy/:resourcePolicyId/users", + verifyResourcePolicyAccess, + verifyUserHasAction(ActionsEnum.listResourcePolicyUsers), + resource.listResourcePolicyUsers +); + +authenticated.post( + "/resource-policy/:resourcePolicyId/roles", + verifyResourcePolicyAccess, + verifyRoleAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.setResourcePolicyRoles), + logActionAudit(ActionsEnum.setResourcePolicyRoles), + resource.setResourcePolicyRoles +); + +authenticated.post( + "/resource-policy/:resourcePolicyId/users", + verifyResourcePolicyAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.setResourcePolicyUsers), + logActionAudit(ActionsEnum.setResourcePolicyUsers), + resource.setResourcePolicyUsers +); + authenticated.post( `/resource/:resourceId/password`, verifyResourceAccess, diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 3ada13d85..3a6ff49f6 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -31,3 +31,7 @@ export * from "./addUserToResource"; export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; +export * from "./listResourcePolicyRoles"; +export * from "./listResourcePolicyUsers"; +export * from "./setResourcePolicyRoles"; +export * from "./setResourcePolicyUsers"; diff --git a/server/routers/resource/listResourcePolicyRoles.ts b/server/routers/resource/listResourcePolicyRoles.ts new file mode 100644 index 000000000..187e46d6b --- /dev/null +++ b/server/routers/resource/listResourcePolicyRoles.ts @@ -0,0 +1,80 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { roleResources, roles } 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 listResourcePolicyRolesSchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +async function query(resourcePolicyId: number) { + return await db + .selectDistinct({ + roleId: roles.roleId, + name: roles.name, + description: roles.description, + isAdmin: roles.isAdmin + }) + .from(roleResources) + .innerJoin(roles, eq(roleResources.roleId, roles.roleId)) + .where(eq(roleResources.resourcePolicyId, resourcePolicyId)); +} + +export type ListResourcePolicyRolesResponse = { + roles: NonNullable>>; +}; + +registry.registerPath({ + method: "get", + path: "/resource-policy/{resourcePolicyId}/roles", + description: "List all roles for a resource policy.", + tags: [OpenAPITags.Resource, OpenAPITags.Role], + request: { + params: listResourcePolicyRolesSchema + }, + responses: {} +}); + +export async function listResourcePolicyRoles( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = listResourcePolicyRolesSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourcePolicyId } = parsedParams.data; + + const policyRolesList = await query(resourcePolicyId); + + return response(res, { + data: { + roles: policyRolesList + }, + success: true, + error: false, + message: "Resource policy roles retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/listResourcePolicyUsers.ts b/server/routers/resource/listResourcePolicyUsers.ts new file mode 100644 index 000000000..a67366bb8 --- /dev/null +++ b/server/routers/resource/listResourcePolicyUsers.ts @@ -0,0 +1,85 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { idp, userResources, users } 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 listResourcePolicyUsersSchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +async function queryUsers(resourcePolicyId: number) { + return await db + .selectDistinct({ + userId: userResources.userId, + username: users.username, + type: users.type, + idpName: idp.name, + idpId: users.idpId, + email: users.email + }) + .from(userResources) + .innerJoin(users, eq(userResources.userId, users.userId)) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .where(eq(userResources.resourcePolicyId, resourcePolicyId)); +} + +export type ListResourcePolicyUsersResponse = { + users: NonNullable>>; +}; + +registry.registerPath({ + method: "get", + path: "/resource-policy/{resourcePolicyId}/users", + description: "List all users for a resource policy.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: listResourcePolicyUsersSchema + }, + responses: {} +}); + +export async function listResourcePolicyUsers( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = listResourcePolicyUsersSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourcePolicyId } = parsedParams.data; + + const policyUsersList = await queryUsers(resourcePolicyId); + + return response(res, { + data: { + users: policyUsersList + }, + success: true, + error: false, + message: "Resource policy users retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/setResourcePolicyRoles.ts b/server/routers/resource/setResourcePolicyRoles.ts new file mode 100644 index 000000000..2a5134f4e --- /dev/null +++ b/server/routers/resource/setResourcePolicyRoles.ts @@ -0,0 +1,165 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resourcePolicies, resources, roleResources, roles } from "@server/db"; +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 { eq, and, ne, inArray } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const setResourcePolicyRolesBodySchema = z.strictObject({ + roleIds: z.array(z.int().positive()) +}); + +const setResourcePolicyRolesParamsSchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/resource-policy/{resourcePolicyId}/roles", + description: + "Set roles for a resource policy. This will replace all existing roles across all resources under this policy.", + tags: [OpenAPITags.Resource, OpenAPITags.Role], + request: { + params: setResourcePolicyRolesParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyRolesBodySchema + } + } + } + }, + responses: {} +}); + +export async function setResourcePolicyRoles( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setResourcePolicyRolesBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { roleIds } = parsedBody.data; + + const parsedParams = setResourcePolicyRolesParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourcePolicyId } = parsedParams.data; + + const [policy] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .limit(1); + + if (!policy) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource policy not found") + ); + } + + // Check if any of the roleIds are admin roles + const rolesToCheck = await db + .select() + .from(roles) + .where( + and( + inArray(roles.roleId, roleIds), + eq(roles.orgId, policy.orgId) + ) + ); + + const hasAdminRole = rolesToCheck.some((role) => role.isAdmin); + if (hasAdminRole) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Admin role cannot be assigned to resource policies" + ) + ); + } + + // Get admin role IDs for this org to exclude from deletion + const adminRoles = await db + .select() + .from(roles) + .where( + and(eq(roles.isAdmin, true), eq(roles.orgId, policy.orgId)) + ); + const adminRoleIds = adminRoles.map((role) => role.roleId); + + // Get all resources under this policy + const policyResources = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where(eq(resources.resourcePolicyId, resourcePolicyId)); + + await db.transaction(async (trx) => { + // Delete existing role associations for this policy (excluding admin roles) + if (adminRoleIds.length > 0) { + await trx.delete(roleResources).where( + and( + eq(roleResources.resourcePolicyId, resourcePolicyId), + ne(roleResources.roleId, adminRoleIds[0]) + ) + ); + } else { + await trx + .delete(roleResources) + .where( + eq(roleResources.resourcePolicyId, resourcePolicyId) + ); + } + + // Insert new role associations for each resource under the policy + if (roleIds.length > 0 && policyResources.length > 0) { + await Promise.all( + policyResources.flatMap(({ resourceId }) => + roleIds.map((roleId) => + trx + .insert(roleResources) + .values({ roleId, resourceId, resourcePolicyId }) + .returning() + ) + ) + ); + } + + return response(res, { + data: {}, + success: true, + error: false, + message: "Roles set for resource policy successfully", + status: HttpCode.CREATED + }); + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/setResourcePolicyUsers.ts b/server/routers/resource/setResourcePolicyUsers.ts new file mode 100644 index 000000000..8f18d93c8 --- /dev/null +++ b/server/routers/resource/setResourcePolicyUsers.ts @@ -0,0 +1,124 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resourcePolicies, resources, userResources } from "@server/db"; +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 { eq } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const setResourcePolicyUsersBodySchema = z.strictObject({ + userIds: z.array(z.string()) +}); + +const setResourcePolicyUsersParamsSchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/resource-policy/{resourcePolicyId}/users", + description: + "Set users for a resource policy. This will replace all existing users across all resources under this policy.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: setResourcePolicyUsersParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyUsersBodySchema + } + } + } + }, + responses: {} +}); + +export async function setResourcePolicyUsers( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setResourcePolicyUsersBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userIds } = parsedBody.data; + + const parsedParams = setResourcePolicyUsersParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourcePolicyId } = parsedParams.data; + + const [policy] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .limit(1); + + if (!policy) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource policy not found") + ); + } + + // Get all resources under this policy + const policyResources = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where(eq(resources.resourcePolicyId, resourcePolicyId)); + + await db.transaction(async (trx) => { + // Delete existing user associations for this policy + await trx + .delete(userResources) + .where(eq(userResources.resourcePolicyId, resourcePolicyId)); + + // Insert new user associations for each resource under the policy + if (userIds.length > 0 && policyResources.length > 0) { + await Promise.all( + policyResources.flatMap(({ resourceId }) => + userIds.map((userId) => + trx + .insert(userResources) + .values({ userId, resourceId, resourcePolicyId }) + .returning() + ) + ) + ); + } + + return response(res, { + data: {}, + success: true, + error: false, + message: "Users set for resource policy successfully", + status: HttpCode.CREATED + }); + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} From a53363d064a717d9e3ff2c80993efb2dca922bc1 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 19 Feb 2026 03:23:54 +0100 Subject: [PATCH 18/89] =?UTF-8?q?=F0=9F=92=84=20include=20rules=20in=20cre?= =?UTF-8?q?ate=20policy=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CreatePolicyForm.tsx | 1119 ++++++++++++++++++++++++++- 1 file changed, 1104 insertions(+), 15 deletions(-) diff --git a/src/components/CreatePolicyForm.tsx b/src/components/CreatePolicyForm.tsx index b1cb49a01..666c3b7ea 100644 --- a/src/components/CreatePolicyForm.tsx +++ b/src/components/CreatePolicyForm.tsx @@ -5,7 +5,6 @@ import { SettingsSection, SettingsSectionBody, SettingsSectionDescription, - SettingsSectionFooter, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle @@ -14,6 +13,14 @@ import { SwitchInput } from "@app/components/SwitchInput"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; import { Form, FormControl, @@ -23,8 +30,13 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; import { InfoPopup } from "@app/components/ui/info-popup"; +import { Input } from "@app/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; import { Select, SelectContent, @@ -32,24 +44,76 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; +import { Switch } from "@app/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { orgQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; import { build } from "@server/build"; +import { MAJOR_ASNS } from "@server/db/asns"; +import { COUNTRIES } from "@server/db/countries"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; import { UserType } from "@server/types/UserTypes"; import { useQuery } from "@tanstack/react-query"; -import { Binary, Bot, InfoIcon, Key } from "lucide-react"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable +} from "@tanstack/react-table"; +import { + ArrowUpDown, + Binary, + Bot, + Check, + ChevronsUpDown, + InfoIcon, + Key +} from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { useActionState, useMemo, useState } from "react"; +import { useActionState, useCallback, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import z from "zod"; +const addRuleSchema = z.object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.coerce.number().int().optional() +}); + +type LocalRule = { + ruleId: number; + action: "ACCEPT" | "DROP" | "PASS"; + match: string; + value: string; + priority: number; + enabled: boolean; + new?: boolean; + updated?: boolean; +}; + const createPolicySchema = z.object({ name: z.string().min(1).max(255), sso: z.boolean().default(true), @@ -72,7 +136,19 @@ const createPolicySchema = z.object({ id: z.string(), text: z.string() }) - ) + ), + applyRules: z.boolean().default(false), + rules: z + .array( + z.object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.number().int(), + enabled: z.boolean() + }) + ) + .default([]) }); export type CreatePolicyFormProps = {}; @@ -86,6 +162,11 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { const router = useRouter(); const { isPaidUser } = usePaidStatus(); + const isMaxmindAvailable = + env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0; + const isMaxmindAsnAvailable = + env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0; + const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery( orgQueries.roles({ orgId: org.org.orgId @@ -112,7 +193,9 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { emailWhitelistEnabled: false, roles: [], users: [], - emails: [] + emails: [], + applyRules: false, + rules: [] } }); @@ -129,10 +212,176 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { number | null >(null); + // Rules state + const [rules, setRules] = useState([]); + const [rulesEnabled, setRulesEnabled] = useState(false); + const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = + useState(false); + const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); + + const addRuleForm = useForm({ + resolver: zodResolver(addRuleSchema), + defaultValues: { + action: "ACCEPT" as const, + match: "IP", + value: "" + } + }); + + const RuleAction = useMemo(() => { + return { + ACCEPT: t("alwaysAllow"), + DROP: t("alwaysDeny"), + PASS: t("passToAuth") + } as const; + }, [t]); + + const RuleMatch = useMemo(() => { + return { + PATH: t("path"), + IP: "IP", + CIDR: t("ipAddressRange"), + COUNTRY: t("country"), + ASN: "ASN" + } as const; + }, [t]); + async function onSubmit() { // ... } + const addRule = useCallback(function addRule(data: z.infer) { + const isDuplicate = rules.some( + (rule) => + rule.action === data.action && + rule.match === data.match && + rule.value === data.value + ); + + if (isDuplicate) { + toast({ + variant: "destructive", + title: t("rulesErrorDuplicate"), + description: t("rulesErrorDuplicateDescription") + }); + return; + } + + if (data.match === "CIDR" && !isValidCIDR(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddressRange"), + description: t("rulesErrorInvalidIpAddressRangeDescription") + }); + return; + } + if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidUrl"), + description: t("rulesErrorInvalidUrlDescription") + }); + return; + } + if (data.match === "IP" && !isValidIP(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddress"), + description: t("rulesErrorInvalidIpAddressDescription") + }); + return; + } + if ( + data.match === "COUNTRY" && + !COUNTRIES.some((c) => c.code === data.value) + ) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidCountry"), + description: t("rulesErrorInvalidCountryDescription") || "" + }); + return; + } + + let priority = data.priority; + if (priority === undefined) { + priority = rules.reduce( + (acc, rule) => (rule.priority > acc ? rule.priority : acc), + 0 + ); + priority++; + } + + const newRule: LocalRule = { + ...data, + ruleId: new Date().getTime(), + new: true, + priority, + enabled: true + }; + + const updatedRules = [...rules, newRule]; + setRules(updatedRules); + form.setValue( + "rules", + updatedRules.map(({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + })) + ); + addRuleForm.reset(); + }, [rules, t, form, addRuleForm]); + + const removeRule = useCallback(function removeRule(ruleId: number) { + const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); + setRules(updatedRules); + form.setValue( + "rules", + updatedRules.map(({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + })) + ); + }, [rules, form]); + + const updateRule = useCallback(function updateRule(ruleId: number, data: Partial) { + const updatedRules = rules.map((rule) => + rule.ruleId === ruleId ? { ...rule, ...data, updated: true } : rule + ); + setRules(updatedRules); + form.setValue( + "rules", + updatedRules.map(({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + })) + ); + }, [rules, form]); + + const getValueHelpText = useCallback(function getValueHelpText(type: string) { + switch (type) { + case "CIDR": + return t("rulesMatchIpAddressRangeDescription"); + case "IP": + return t("rulesMatchIpAddress"); + case "PATH": + return t("rulesMatchUrl"); + case "COUNTRY": + return t("rulesMatchCountry"); + case "ASN": + return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; + } + }, [t]); + const allRoles = useMemo(() => { return orgRoles .map((role) => ({ @@ -169,6 +418,348 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { return []; }, [orgIdps]); + const columns: ColumnDef[] = useMemo( + () => [ + { + accessorKey: "priority", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + e.currentTarget.focus()} + onBlur={(e) => { + const parsed = z.coerce + .number() + .int() + .optional() + .safeParse(e.target.value); + if (!parsed.success) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidPriority"), + description: t( + "rulesErrorInvalidPriorityDescription" + ) + }); + return; + } + updateRule(row.original.ruleId, { + priority: parsed.data + }); + }} + /> + ) + }, + { + accessorKey: "action", + header: () => {t("rulesAction")}, + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "match", + header: () => ( + {t("rulesMatchType")} + ), + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "value", + header: () => {t("value")}, + cell: ({ row }) => + row.original.match === "COUNTRY" ? ( + + + + + + + + + + {t("noCountryFound")} + + + {COUNTRIES.map((country) => ( + { + updateRule( + row.original.ruleId, + { + value: country.code + } + ); + }} + > + + {country.name} ( + {country.code}) + + ))} + + + + + + ) : row.original.match === "ASN" ? ( + + + + + + + + + + No ASN found. Enter a custom ASN + below. + + + {MAJOR_ASNS.map((asn) => ( + { + updateRule( + row.original.ruleId, + { value: asn.code } + ); + }} + > + + {asn.name} ({asn.code}) + + ))} + + + +
+ + asn.code === + row.original.value + ) + ? row.original.value + : "" + } + onKeyDown={(e) => { + if (e.key === "Enter") { + const value = + e.currentTarget.value + .toUpperCase() + .replace(/^AS/, ""); + if (/^\d+$/.test(value)) { + updateRule( + row.original.ruleId, + { value: "AS" + value } + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + updateRule(row.original.ruleId, { + value: e.target.value + }) + } + /> + ) + }, + { + accessorKey: "enabled", + header: () => {t("enabled")}, + cell: ({ row }) => ( + + updateRule(row.original.ruleId, { enabled: val }) + } + /> + ) + }, + { + id: "actions", + header: () => {t("actions")}, + cell: ({ row }) => ( +
+ +
+ ) + } + ], + [ + t, + RuleAction, + RuleMatch, + isMaxmindAvailable, + isMaxmindAsnAvailable, + updateRule, + removeRule + ] + ); + + const table = useReactTable({ + data: rules, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { + pagination: { + pageIndex: 0, + pageSize: 1000 + } + } + }); + const pageLoading = isLoadingOrgRoles || isLoadingOrgUsers || isLoadingOrgIdps; @@ -603,17 +1194,515 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { )} - - - + + + {/* Rules */} + + + + {t("rulesResource")} + + + {t("rulesResourceDescription")} + + + +
+
+ { + setRulesEnabled(val); + form.setValue("applyRules", val); + }} + /> +
+ +
+ +
+ ( + + + {t("rulesAction")} + + + + + + + )} + /> + ( + + + {t( + "rulesMatchType" + )} + + + + + + + )} + /> + ( + + + + {addRuleForm.watch( + "match" + ) === "COUNTRY" ? ( + + + + + + + + + + {t( + "noCountryFound" + )} + + + {COUNTRIES.map( + ( + country + ) => ( + { + field.onChange( + country.code + ); + setOpenAddRuleCountrySelect( + false + ); + }} + > + + { + country.name + }{" "} + ( + { + country.code + } + + ) + + ) + )} + + + + + + ) : addRuleForm.watch( + "match" + ) === "ASN" ? ( + + + + + + + + + + No + ASN + found. + Use + the + custom + input + below. + + + {MAJOR_ASNS.map( + ( + asn + ) => ( + { + field.onChange( + asn.code + ); + setOpenAddRuleAsnSelect( + false + ); + }} + > + + { + asn.name + }{" "} + ( + { + asn.code + } + + ) + + ) + )} + + + +
+ { + if ( + e.key === + "Enter" + ) { + const value = + e.currentTarget.value + .toUpperCase() + .replace( + /^AS/, + "" + ); + if ( + /^\d+$/.test( + value + ) + ) { + field.onChange( + "AS" + + value + ); + setOpenAddRuleAsnSelect( + false + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + )} +
+ +
+ )} + /> + +
+
+ + + + + {table + .getHeaderGroups() + .map((headerGroup) => ( + + {headerGroup.headers.map( + (header) => { + const isActionsColumn = + header.column + .id === + "actions"; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header + .column + .columnDef + .header, + header.getContext() + )} + + ); + } + )} + + ))} + + + {table.getRowModel().rows?.length ? ( + table + .getRowModel() + .rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => { + const isActionsColumn = + cell.column + .id === + "actions"; + return ( + + {flexRender( + cell + .column + .columnDef + .cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + + {t("rulesNoOne")} + + + )} + +
+
+
+ +
+ +
); From c3fdda026b99de4be55ae1bea4faaba02af9bf1d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 19 Feb 2026 04:36:42 +0100 Subject: [PATCH 19/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20separate=20into=20di?= =?UTF-8?q?ff=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CreatePolicyForm.tsx | 2250 +++++++++++++-------------- 1 file changed, 1076 insertions(+), 1174 deletions(-) diff --git a/src/components/CreatePolicyForm.tsx b/src/components/CreatePolicyForm.tsx index 666c3b7ea..96154edfe 100644 --- a/src/components/CreatePolicyForm.tsx +++ b/src/components/CreatePolicyForm.tsx @@ -57,7 +57,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; -import { createApiClient } from "@app/lib/api"; + import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { orgQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -91,11 +91,13 @@ import { Key } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useRouter } from "next/navigation"; + import { useActionState, useCallback, useMemo, useState } from "react"; -import { useForm } from "react-hook-form"; +import { UseFormReturn, useForm } from "react-hook-form"; import z from "zod"; +// ─── Schemas & types ────────────────────────────────────────────────────────── + const addRuleSchema = z.object({ action: z.enum(["ACCEPT", "DROP", "PASS"]), match: z.string(), @@ -119,24 +121,9 @@ const createPolicySchema = z.object({ sso: z.boolean().default(true), skipToIdpId: z.number().nullable().optional(), emailWhitelistEnabled: z.boolean().default(false), - roles: z.array( - z.object({ - id: z.string(), - text: z.string() - }) - ), - users: z.array( - z.object({ - id: z.string(), - text: z.string() - }) - ), - emails: z.array( - z.object({ - id: z.string(), - text: z.string() - }) - ), + roles: z.array(z.object({ id: z.string(), text: z.string() })), + users: z.array(z.object({ id: z.string(), text: z.string() })), + emails: z.array(z.object({ id: z.string(), text: z.string() })), applyRules: z.boolean().default(false), rules: z .array( @@ -151,31 +138,31 @@ const createPolicySchema = z.object({ .default([]) }); +type PolicyFormValues = z.infer; + +// ─── CreatePolicyForm ───────────────────────────────────────────────────────── + export type CreatePolicyFormProps = {}; export function CreatePolicyForm({}: CreatePolicyFormProps) { const { org } = useOrgContext(); const t = useTranslations(); const { env } = useEnvContext(); - const api = createApiClient({ env }); const [, formAction, isSubmitting] = useActionState(onSubmit, null); - const router = useRouter(); const { isPaidUser } = usePaidStatus(); - const isMaxmindAvailable = - env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0; - const isMaxmindAsnAvailable = - env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0; + const isMaxmindAvailable = !!( + env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0 + ); + const isMaxmindAsnAvailable = !!( + env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0 + ); const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery( - orgQueries.roles({ - orgId: org.org.orgId - }) + orgQueries.roles({ orgId: org.org.orgId }) ); const { data: orgUsers = [], isLoading: isLoadingOrgUsers } = useQuery( - orgQueries.users({ - orgId: org.org.orgId - }) + orgQueries.users({ orgId: org.org.orgId }) ); const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery( orgQueries.identityProviders({ @@ -184,8 +171,8 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { }) ); - const form = useForm({ - resolver: zodResolver(createPolicySchema), + const form = useForm({ + resolver: zodResolver(createPolicySchema) as any, defaultValues: { name: "", sso: true, @@ -199,8 +186,135 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { } }); + async function onSubmit() { + // ... + } + + const allRoles = useMemo( + () => + orgRoles + .map((role) => ({ + id: role.roleId.toString(), + text: role.name + })) + .filter((role) => role.text !== "Admin"), + [orgRoles] + ); + + const allUsers = useMemo( + () => + orgUsers.map((user) => ({ + id: user.id.toString(), + text: `${getUserDisplayName({ email: user.email, username: user.username })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` + })), + [orgUsers] + ); + + const allIdps = useMemo(() => { + if (build === "saas") { + if (isPaidUser(tierMatrix.orgOidc)) { + return orgIdps.map((idp) => ({ + id: idp.idpId, + text: idp.name + })); + } + } else { + return orgIdps.map((idp) => ({ id: idp.idpId, text: idp.name })); + } + return []; + }, [orgIdps, isPaidUser]); + + if (isLoadingOrgRoles || isLoadingOrgUsers || isLoadingOrgIdps) { + return <>; + } + + return ( +
+ + + {/* Name */} + + + + {t("resourcePolicyName")} + + + {t("resourcePolicyNameDescription")} + + + + + ( + + {t("name")} + + + + + + )} + /> + + + + + + + + + + +
+ +
+
+ + ); +} + +// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── + +type PolicyUsersRolesSectionProps = { + form: UseFormReturn; + allRoles: { id: string; text: string }[]; + allUsers: { id: string; text: string }[]; + allIdps: { id: number; text: string }[]; +}; + +function PolicyUsersRolesSection({ + form, + allRoles, + allUsers, + allIdps +}: PolicyUsersRolesSectionProps) { + const t = useTranslations(); const [ssoEnabled, setSsoEnabled] = useState(true); - const [whitelistEnabled, setWhitelistEnabled] = useState(false); const [selectedIdpId, setSelectedIdpId] = useState(null); const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< number | null @@ -208,11 +322,364 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< number | null >(null); + + return ( + + + + {t("resourceUsersRoles")} + + + {t("resourceUsersRolesDescription")} + + + + + { + setSsoEnabled(val); + form.setValue("sso", val); + }} + /> + + {ssoEnabled && ( + <> + ( + + {t("roles")} + + { + form.setValue( + "roles", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allRoles} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + {t("resourceRoleDescription")} + + + )} + /> + ( + + {t("users")} + + { + form.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allUsers} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + + )} + + {ssoEnabled && allIdps.length > 0 && ( +
+ + +

+ {t("defaultIdentityProviderDescription")} +

+
+ )} +
+
+
+ ); +} + +// ─── PolicyAuthMethodsSection ───────────────────────────────────────────────── + +function PolicyAuthMethodsSection() { + const t = useTranslations(); + return ( + + + + {t("resourceAuthMethods")} + + + {t("resourceAuthMethodsDescriptions")} + + + + +
+
+ + + {t("resourcePasswordProtection", { + status: t("disabled") + })} + +
+ +
+ +
+
+ + + {t("resourcePincodeProtection", { + status: t("disabled") + })} + +
+ +
+ +
+
+ + + {t("resourceHeaderAuthProtectionDisabled")} + +
+ +
+
+
+
+ ); +} + +// ─── PolicyOtpEmailSection ──────────────────────────────────────────────────── + +type PolicyOtpEmailSectionProps = { + form: UseFormReturn; + emailEnabled: boolean; +}; + +function PolicyOtpEmailSection({ + form, + emailEnabled +}: PolicyOtpEmailSectionProps) { + const t = useTranslations(); + const [whitelistEnabled, setWhitelistEnabled] = useState(false); const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< number | null >(null); - // Rules state + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + {!emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + { + setWhitelistEnabled(val); + form.setValue("emailWhitelistEnabled", val); + }} + disabled={!emailEnabled} + /> + + {whitelistEnabled && emailEnabled && ( + ( + + + + + + {/* @ts-ignore */} + { + return z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: t( + "otpEmailErrorInvalid" + ) + } + ) + ) + .safeParse(tag).success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder={t("otpEmailEnter")} + tags={form.getValues().emails} + setTags={(newEmails) => { + form.setValue( + "emails", + newEmails as [Tag, ...Tag[]] + ); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t("otpEmailEnterDescription")} + + + )} + /> + )} + + + + ); +} + +// ─── PolicyRulesSection ─────────────────────────────────────────────────────── + +type PolicyRulesSectionProps = { + form: UseFormReturn; + isMaxmindAvailable: boolean; + isMaxmindAsnAvailable: boolean; +}; + +function PolicyRulesSection({ + form, + isMaxmindAvailable, + isMaxmindAsnAvailable +}: PolicyRulesSectionProps) { + const t = useTranslations(); const [rules, setRules] = useState([]); const [rulesEnabled, setRulesEnabled] = useState(false); const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = @@ -228,195 +695,162 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { } }); - const RuleAction = useMemo(() => { - return { + const RuleAction = useMemo( + () => ({ ACCEPT: t("alwaysAllow"), DROP: t("alwaysDeny"), PASS: t("passToAuth") - } as const; - }, [t]); + }), + [t] + ); - const RuleMatch = useMemo(() => { - return { + const RuleMatch = useMemo( + () => ({ PATH: t("path"), IP: "IP", CIDR: t("ipAddressRange"), COUNTRY: t("country"), ASN: "ASN" - } as const; - }, [t]); + }), + [t] + ); - async function onSubmit() { - // ... - } - - const addRule = useCallback(function addRule(data: z.infer) { - const isDuplicate = rules.some( - (rule) => - rule.action === data.action && - rule.match === data.match && - rule.value === data.value - ); - - if (isDuplicate) { - toast({ - variant: "destructive", - title: t("rulesErrorDuplicate"), - description: t("rulesErrorDuplicateDescription") - }); - return; - } - - if (data.match === "CIDR" && !isValidCIDR(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidIpAddressRange"), - description: t("rulesErrorInvalidIpAddressRangeDescription") - }); - return; - } - if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidUrl"), - description: t("rulesErrorInvalidUrlDescription") - }); - return; - } - if (data.match === "IP" && !isValidIP(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidIpAddress"), - description: t("rulesErrorInvalidIpAddressDescription") - }); - return; - } - if ( - data.match === "COUNTRY" && - !COUNTRIES.some((c) => c.code === data.value) - ) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidCountry"), - description: t("rulesErrorInvalidCountryDescription") || "" - }); - return; - } - - let priority = data.priority; - if (priority === undefined) { - priority = rules.reduce( - (acc, rule) => (rule.priority > acc ? rule.priority : acc), - 0 + const syncFormRules = useCallback( + (updatedRules: LocalRule[]) => { + form.setValue( + "rules", + updatedRules.map( + ({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + }) + ) ); - priority++; - } + }, + [form] + ); - const newRule: LocalRule = { - ...data, - ruleId: new Date().getTime(), - new: true, - priority, - enabled: true - }; - - const updatedRules = [...rules, newRule]; - setRules(updatedRules); - form.setValue( - "rules", - updatedRules.map(({ action, match, value, priority, enabled }) => ({ - action, - match, - value, - priority, - enabled - })) - ); - addRuleForm.reset(); - }, [rules, t, form, addRuleForm]); - - const removeRule = useCallback(function removeRule(ruleId: number) { - const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); - setRules(updatedRules); - form.setValue( - "rules", - updatedRules.map(({ action, match, value, priority, enabled }) => ({ - action, - match, - value, - priority, - enabled - })) - ); - }, [rules, form]); - - const updateRule = useCallback(function updateRule(ruleId: number, data: Partial) { - const updatedRules = rules.map((rule) => - rule.ruleId === ruleId ? { ...rule, ...data, updated: true } : rule - ); - setRules(updatedRules); - form.setValue( - "rules", - updatedRules.map(({ action, match, value, priority, enabled }) => ({ - action, - match, - value, - priority, - enabled - })) - ); - }, [rules, form]); - - const getValueHelpText = useCallback(function getValueHelpText(type: string) { - switch (type) { - case "CIDR": - return t("rulesMatchIpAddressRangeDescription"); - case "IP": - return t("rulesMatchIpAddress"); - case "PATH": - return t("rulesMatchUrl"); - case "COUNTRY": - return t("rulesMatchCountry"); - case "ASN": - return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; - } - }, [t]); - - const allRoles = useMemo(() => { - return orgRoles - .map((role) => ({ - id: role.roleId.toString(), - text: role.name - })) - .filter((role) => role.text !== "Admin"); - }, [orgRoles]); - - const allUsers = useMemo(() => { - return orgUsers.map((user) => ({ - id: user.id.toString(), - text: `${getUserDisplayName({ - email: user.email, - username: user.username - })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` - })); - }, [orgUsers]); - - const allIdps = useMemo(() => { - if (build === "saas") { - if (isPaidUser(tierMatrix.orgOidc)) { - return orgIdps.map((idp) => ({ - id: idp.idpId, - text: idp.name - })); + const addRule = useCallback( + function addRule(data: z.infer) { + const isDuplicate = rules.some( + (rule) => + rule.action === data.action && + rule.match === data.match && + rule.value === data.value + ); + if (isDuplicate) { + toast({ + variant: "destructive", + title: t("rulesErrorDuplicate"), + description: t("rulesErrorDuplicateDescription") + }); + return; } - } else { - return orgIdps.map((idp) => ({ - id: idp.idpId, - text: idp.name - })); - } - return []; - }, [orgIdps]); + if (data.match === "CIDR" && !isValidCIDR(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddressRange"), + description: t("rulesErrorInvalidIpAddressRangeDescription") + }); + return; + } + if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidUrl"), + description: t("rulesErrorInvalidUrlDescription") + }); + return; + } + if (data.match === "IP" && !isValidIP(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddress"), + description: t("rulesErrorInvalidIpAddressDescription") + }); + return; + } + if ( + data.match === "COUNTRY" && + !COUNTRIES.some((c) => c.code === data.value) + ) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidCountry"), + description: t("rulesErrorInvalidCountryDescription") || "" + }); + return; + } + + let priority = data.priority; + if (priority === undefined) { + priority = + rules.reduce( + (acc, rule) => + rule.priority > acc ? rule.priority : acc, + 0 + ) + 1; + } + + const updatedRules = [ + ...rules, + { + ...data, + ruleId: new Date().getTime(), + new: true, + priority, + enabled: true + } + ]; + setRules(updatedRules); + syncFormRules(updatedRules); + addRuleForm.reset(); + }, + [rules, t, addRuleForm, syncFormRules] + ); + + const removeRule = useCallback( + function removeRule(ruleId: number) { + const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules] + ); + + const updateRule = useCallback( + function updateRule(ruleId: number, data: Partial) { + const updatedRules = rules.map((rule) => + rule.ruleId === ruleId + ? { ...rule, ...data, updated: true } + : rule + ); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules] + ); + + const getValueHelpText = useCallback( + function getValueHelpText(type: string) { + switch (type) { + case "CIDR": + return t("rulesMatchIpAddressRangeDescription"); + case "IP": + return t("rulesMatchIpAddress"); + case "PATH": + return t("rulesMatchUrl"); + case "COUNTRY": + return t("rulesMatchCountry"); + case "ASN": + return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; + } + }, + [t] + ); const columns: ColumnDef[] = useMemo( () => [ @@ -550,9 +984,8 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { > {row.original.value ? COUNTRIES.find( - (country) => - country.code === - row.original.value + (c) => + c.code === row.original.value )?.name + " (" + row.original.value + @@ -575,23 +1008,17 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { { + onSelect={() => updateRule( row.original.ruleId, { value: country.code } - ); - }} + ) + } > {country.name} ( {country.code}) @@ -642,21 +1069,15 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { " " + asn.code } - onSelect={() => { + onSelect={() => updateRule( row.original.ruleId, { value: asn.code } - ); - }} + ) + } > {asn.name} ({asn.code}) @@ -752,958 +1173,439 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), - state: { - pagination: { - pageIndex: 0, - pageSize: 1000 - } - } + state: { pagination: { pageIndex: 0, pageSize: 1000 } } }); - const pageLoading = - isLoadingOrgRoles || isLoadingOrgUsers || isLoadingOrgIdps; - - if (pageLoading) { - return <>; - } - return ( -
- - - {/* Name */} - - - - {t("resourcePolicyName")} - - - {t("resourcePolicyNameDescription")} - - - - + + + + {t("rulesResource")} + + + {t("rulesResourceDescription")} + + + +
+
+ { + setRulesEnabled(val); + form.setValue("applyRules", val); + }} + /> +
+ + + +
( - {t("name")} + + {t("rulesAction")} + - + )} /> - - - - - {/* Users & Roles */} - - - - {t("resourceUsersRoles")} - - - {t("resourceUsersRolesDescription")} - - - - - { - setSsoEnabled(val); - form.setValue("sso", val); - }} - /> - - {ssoEnabled && ( - <> - ( - - - {t("roles")} - - - { - form.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allRoles - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - {t( - "resourceRoleDescription" - )} - - - )} - /> - ( - - - {t("users")} - - - { - form.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allUsers - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - - )} - - {ssoEnabled && allIdps.length > 0 && ( -
- - -

- {t( - "defaultIdentityProviderDescription" - )} -

-
- )} -
-
-
- - {/* Auth Methods */} - - - - {t("resourceAuthMethods")} - - - {t("resourceAuthMethodsDescriptions")} - - - - -
-
- - - {t("resourcePasswordProtection", { - status: t("disabled") - })} - -
- -
- -
-
- - - {t("resourcePincodeProtection", { - status: t("disabled") - })} - -
- -
- -
-
- - - {t( - "resourceHeaderAuthProtectionDisabled" - )} - -
- -
-
-
-
- - {/* OTP Email */} - - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - - {!env.email.emailEnabled && ( - - - - {t("otpEmailSmtpRequired")} - - - {t( - "otpEmailSmtpRequiredDescription" - )} - - - )} - { - setWhitelistEnabled(val); - form.setValue( - "emailWhitelistEnabled", - val - ); - }} - disabled={!env.email.emailEnabled} - /> - - {whitelistEnabled && env.email.emailEnabled && ( - ( - - - - - - {/* @ts-ignore */} - { - return z - .email() - .or( - z - .string() - .regex( - /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, - { - message: - t( - "otpEmailErrorInvalid" - ) - } - ) - ) - .safeParse(tag) - .success; - }} - setActiveTagIndex={ - setActiveEmailTagIndex - } - placeholder={t( - "otpEmailEnter" - )} - tags={ - form.getValues() - .emails - } - setTags={( - newEmails - ) => { - form.setValue( - "emails", - newEmails as [ - Tag, - ...Tag[] - ] - ); - }} - allowDuplicates={false} - sortTags={true} - /> - - - {t( - "otpEmailEnterDescription" - )} - - - )} - /> - )} - - - - - {/* Rules */} - - - - {t("rulesResource")} - - - {t("rulesResourceDescription")} - - - -
-
- { - setRulesEnabled(val); - form.setValue("applyRules", val); - }} - /> -
- - - -
- ( - - - {t("rulesAction")} - - - - - - - )} - /> - ( - - - {t( - "rulesMatchType" - )} - - - - - - - )} - /> - ( - - - - {addRuleForm.watch( - "match" - ) === "COUNTRY" ? ( - - - - - - - - - - {t( - "noCountryFound" - )} - - - {COUNTRIES.map( - ( - country - ) => ( - { - field.onChange( - country.code - ); - setOpenAddRuleCountrySelect( - false - ); - }} - > - - { - country.name - }{" "} - ( - { - country.code - } - - ) - - ) - )} - - - - - - ) : addRuleForm.watch( - "match" - ) === "ASN" ? ( - - - - - - - - - - No - ASN - found. - Use - the - custom - input - below. - - - {MAJOR_ASNS.map( - ( - asn - ) => ( - { - field.onChange( - asn.code - ); - setOpenAddRuleAsnSelect( - false - ); - }} - > - - { - asn.name - }{" "} - ( - { - asn.code - } - - ) - - ) - )} - - - -
- { - if ( - e.key === - "Enter" - ) { - const value = - e.currentTarget.value - .toUpperCase() - .replace( - /^AS/, - "" - ); - if ( - /^\d+$/.test( - value - ) - ) { - field.onChange( - "AS" + - value - ); - setOpenAddRuleAsnSelect( - false - ); - } - } - }} - className="text-sm" - /> -
-
-
- ) : ( - - )} -
- -
- )} - /> - -
- - - - - - {table - .getHeaderGroups() - .map((headerGroup) => ( - - {headerGroup.headers.map( - (header) => { - const isActionsColumn = - header.column - .id === - "actions"; - return ( - - {header.isPlaceholder - ? null - : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} - - ); - } - )} - - ))} - - - {table.getRowModel().rows?.length ? ( - table - .getRowModel() - .rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => { - const isActionsColumn = - cell.column - .id === - "actions"; - return ( - - {flexRender( - cell - .column - .columnDef - .cell, - cell.getContext() - )} - - ); - })} - - )) - ) : ( - - ( + + + {t("rulesMatchType")} + + +
-
-
-
- + + + + + + {RuleMatch.PATH} + + + {RuleMatch.IP} + + + {RuleMatch.CIDR} + + {isMaxmindAvailable && ( + + { + RuleMatch.COUNTRY + } + + )} + {isMaxmindAsnAvailable && ( + + {RuleMatch.ASN} + + )} + + + + + + )} + /> + ( + + + + {addRuleForm.watch("match") === + "COUNTRY" ? ( + + + + + + + + + + {t( + "noCountryFound" + )} + + + {COUNTRIES.map( + ( + country + ) => ( + { + field.onChange( + country.code + ); + setOpenAddRuleCountrySelect( + false + ); + }} + > + + { + country.name + }{" "} + ( + { + country.code + } -
- + ) + + ) + )} + + + + + + ) : addRuleForm.watch( + "match" + ) === "ASN" ? ( + + + + + + + + + + No ASN + found. + Use the + custom + input + below. + + + {MAJOR_ASNS.map( + ( + asn + ) => ( + { + field.onChange( + asn.code + ); + setOpenAddRuleAsnSelect( + false + ); + }} + > + + { + asn.name + }{" "} + ( + { + asn.code + } + + ) + + ) + )} + + + +
+ { + if ( + e.key === + "Enter" + ) { + const value = + e.currentTarget.value + .toUpperCase() + .replace( + /^AS/, + "" + ); + if ( + /^\d+$/.test( + value + ) + ) { + field.onChange( + "AS" + + value + ); + setOpenAddRuleAsnSelect( + false + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + )} + + + + )} + /> + +
+ + + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isActionsColumn = + header.column.id === "actions"; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column + .columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const isActionsColumn = + cell.column.id === "actions"; + return ( + + {flexRender( + cell.column.columnDef + .cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + + {t("rulesNoOne")} + + + )} + +
- - + + ); } From 003bf7fdf32a4ec52de5ff4f6d8222f141961f0c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 19 Feb 2026 04:59:51 +0100 Subject: [PATCH 20/89] =?UTF-8?q?=F0=9F=9A=B8=20hide=20otp,=20rules=20and?= =?UTF-8?q?=20resource=20rules=20config=20by=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 6 +- .../resources/policies/create/page.tsx | 2 +- src/components/CreatePolicyForm.tsx | 91 +++++++++++++++++-- src/components/tags/tag-popover.tsx | 10 +- 4 files changed, 98 insertions(+), 11 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 58a772967..91cf42c53 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -176,7 +176,11 @@ "resourcePolicyName": "Policy Name", "resourcePolicyNameDescription": "Give this policy a name to identify it across your resources", "resourcePolicyNamePlaceholder": "e.g. Internal Access Policy", - "policiesSeeAll": "See All Policies", + "resourcePoliciesSeeAll": "See All Policies", + "resourcePolicyAuthMethodAdd": "Add Authentication Method", + "resourcePolicyOtpEmailAdd": "Add OTP emails", + "resourcePolicyRulesAdd": "Add Rules", + "rulesResourcePolicyDescription": "Configure rules to control access resources associated to this policy", "authentication": "Authentication", "protected": "Protected", "notProtected": "Not Protected", diff --git a/src/app/[orgId]/settings/(private)/resources/policies/create/page.tsx b/src/app/[orgId]/settings/(private)/resources/policies/create/page.tsx index e43ef39ee..19fd2ca37 100644 --- a/src/app/[orgId]/settings/(private)/resources/policies/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/resources/policies/create/page.tsx @@ -35,7 +35,7 @@ export default async function CreateResourcePolicyPage(
diff --git a/src/components/CreatePolicyForm.tsx b/src/components/CreatePolicyForm.tsx index 96154edfe..01d56fa46 100644 --- a/src/components/CreatePolicyForm.tsx +++ b/src/components/CreatePolicyForm.tsx @@ -88,7 +88,8 @@ import { Check, ChevronsUpDown, InfoIcon, - Key + Key, + Plus } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -314,8 +315,8 @@ function PolicyUsersRolesSection({ allIdps }: PolicyUsersRolesSectionProps) { const t = useTranslations(); - const [ssoEnabled, setSsoEnabled] = useState(true); - const [selectedIdpId, setSelectedIdpId] = useState(null); + const ssoEnabled = form.watch("sso"); + const selectedIdpId = form.watch("skipToIdpId"); const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< number | null >(null); @@ -338,9 +339,8 @@ function PolicyUsersRolesSection({ { - setSsoEnabled(val); form.setValue("sso", val); }} /> @@ -445,11 +445,9 @@ function PolicyUsersRolesSection({ + + + + )} + /> +
+
+
+ + + + + +
+ +
+ +
+ + + ); +} diff --git a/src/components/CreatePolicyForm.tsx b/src/components/resource-policy/ResourcePolicySubForms.tsx similarity index 79% rename from src/components/CreatePolicyForm.tsx rename to src/components/resource-policy/ResourcePolicySubForms.tsx index 01d56fa46..bcb0f0470 100644 --- a/src/components/CreatePolicyForm.tsx +++ b/src/components/resource-policy/ResourcePolicySubForms.tsx @@ -1,7 +1,6 @@ "use client"; import { - SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, @@ -53,25 +52,31 @@ import { TableHeader, TableRow } from "@app/components/ui/table"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; import { toast } from "@app/hooks/useToast"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot +} from "@app/components/ui/input-otp"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { orgQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; -import { build } from "@server/build"; import { MAJOR_ASNS } from "@server/db/asns"; import { COUNTRIES } from "@server/db/countries"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "@server/lib/validators"; -import { UserType } from "@server/types/UserTypes"; -import { useQuery } from "@tanstack/react-query"; import { ColumnDef, flexRender, @@ -93,11 +98,10 @@ import { } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useActionState, useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { UseFormReturn, useForm } from "react-hook-form"; import z from "zod"; - -// ─── Schemas & types ────────────────────────────────────────────────────────── +import type { PolicyFormValues } from "."; const addRuleSchema = z.object({ action: z.enum(["ACCEPT", "DROP", "PASS"]), @@ -117,190 +121,6 @@ type LocalRule = { updated?: boolean; }; -const createPolicySchema = z.object({ - name: z.string().min(1).max(255), - sso: z.boolean().default(true), - skipToIdpId: z.number().nullable().optional(), - emailWhitelistEnabled: z.boolean().default(false), - roles: z.array(z.object({ id: z.string(), text: z.string() })), - users: z.array(z.object({ id: z.string(), text: z.string() })), - emails: z.array(z.object({ id: z.string(), text: z.string() })), - applyRules: z.boolean().default(false), - rules: z - .array( - z.object({ - action: z.enum(["ACCEPT", "DROP", "PASS"]), - match: z.string(), - value: z.string(), - priority: z.number().int(), - enabled: z.boolean() - }) - ) - .default([]) -}); - -type PolicyFormValues = z.infer; - -// ─── CreatePolicyForm ───────────────────────────────────────────────────────── - -export type CreatePolicyFormProps = {}; - -export function CreatePolicyForm({}: CreatePolicyFormProps) { - const { org } = useOrgContext(); - const t = useTranslations(); - const { env } = useEnvContext(); - const [, formAction, isSubmitting] = useActionState(onSubmit, null); - const { isPaidUser } = usePaidStatus(); - - const isMaxmindAvailable = !!( - env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0 - ); - const isMaxmindAsnAvailable = !!( - env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0 - ); - - const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery( - orgQueries.roles({ orgId: org.org.orgId }) - ); - const { data: orgUsers = [], isLoading: isLoadingOrgUsers } = useQuery( - orgQueries.users({ orgId: org.org.orgId }) - ); - const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery( - orgQueries.identityProviders({ - orgId: org.org.orgId, - useOrgOnlyIdp: env.app.identityProviderMode === "org" - }) - ); - - const form = useForm({ - resolver: zodResolver(createPolicySchema) as any, - defaultValues: { - name: "", - sso: true, - skipToIdpId: null, - emailWhitelistEnabled: false, - roles: [], - users: [], - emails: [], - applyRules: false, - rules: [] - } - }); - - async function onSubmit() { - // ... - } - - const allRoles = useMemo( - () => - orgRoles - .map((role) => ({ - id: role.roleId.toString(), - text: role.name - })) - .filter((role) => role.text !== "Admin"), - [orgRoles] - ); - - const allUsers = useMemo( - () => - orgUsers.map((user) => ({ - id: user.id.toString(), - text: `${getUserDisplayName({ email: user.email, username: user.username })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` - })), - [orgUsers] - ); - - const allIdps = useMemo(() => { - if (build === "saas") { - if (isPaidUser(tierMatrix.orgOidc)) { - return orgIdps.map((idp) => ({ - id: idp.idpId, - text: idp.name - })); - } - } else { - return orgIdps.map((idp) => ({ id: idp.idpId, text: idp.name })); - } - return []; - }, [orgIdps, isPaidUser]); - - if (isLoadingOrgRoles || isLoadingOrgUsers || isLoadingOrgIdps) { - return <>; - } - - return ( -
- - - {/* Name */} - - - - {t("resourcePolicyName")} - - - {t("resourcePolicyNameDescription")} - - - - - ( - - {t("name")} - - - - - - )} - /> - - - - - - - - - - -
- -
-
- - ); -} - -// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── - type PolicyUsersRolesSectionProps = { form: UseFormReturn; allRoles: { id: string; text: string }[]; @@ -308,7 +128,9 @@ type PolicyUsersRolesSectionProps = { allIdps: { id: number; text: string }[]; }; -function PolicyUsersRolesSection({ +// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── + +export function PolicyUsersRolesSection({ form, allRoles, allUsers, @@ -331,7 +153,7 @@ function PolicyUsersRolesSection({ {t("resourceUsersRoles")}
- {t("resourceUsersRolesDescription")} + {t("resourcePolicyUsersRolesDescription")} @@ -489,9 +311,51 @@ function PolicyUsersRolesSection({ // ─── PolicyAuthMethodsSection ───────────────────────────────────────────────── -function PolicyAuthMethodsSection() { +const setPasswordSchema = z.object({ + password: z.string().min(4).max(100) +}); + +const setPincodeSchema = z.object({ + pincode: z.string().length(6) +}); + +const setHeaderAuthSchema = z.object({ + user: z.string().min(4).max(100), + password: z.string().min(4).max(100), + extendedCompatibility: z.boolean() +}); + +type PolicyAuthMethodsSectionProps = { + form: UseFormReturn; +}; + +export function PolicyAuthMethodsSection({ + form +}: PolicyAuthMethodsSectionProps) { const t = useTranslations(); const [isOpen, setIsOpen] = useState(false); + const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); + const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); + const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); + + const password = form.watch("password"); + const pincode = form.watch("pincode"); + const headerAuth = form.watch("headerAuth"); + + const passwordForm = useForm({ + resolver: zodResolver(setPasswordSchema), + defaultValues: { password: "" } + }); + + const pincodeForm = useForm({ + resolver: zodResolver(setPincodeSchema), + defaultValues: { pincode: "" } + }); + + const headerAuthForm = useForm({ + resolver: zodResolver(setHeaderAuthSchema), + defaultValues: { user: "", password: "", extendedCompatibility: true } + }); if (!isOpen) { return ( @@ -501,7 +365,7 @@ function PolicyAuthMethodsSection() { {t("resourceAuthMethods")}
- {t("resourceAuthMethodsDescriptions")} + {t("resourcePolicyAuthMethodsDescription")} @@ -519,59 +383,359 @@ function PolicyAuthMethodsSection() { } return ( - - - - {t("resourceAuthMethods")} - - - {t("resourceAuthMethodsDescriptions")} - - - - -
-
- - - {t("resourcePasswordProtection", { - status: t("disabled") + <> + {/* Password Credenza */} + { + setIsSetPasswordOpen(val); + if (!val) passwordForm.reset(); + }} + > + + + + {t("resourcePasswordSetupTitle")} + + + {t("resourcePasswordSetupTitleDescription")} + + + +
+ { + form.setValue("password", data); + setIsSetPasswordOpen(false); + passwordForm.reset(); })} - -
- + + -
+ + + -
-
- - - {t("resourcePincodeProtection", { - status: t("disabled") + {/* Pincode Credenza */} + { + setIsSetPincodeOpen(val); + if (!val) pincodeForm.reset(); + }} + > + + + + {t("resourcePincodeSetupTitle")} + + + {t("resourcePincodeSetupTitleDescription")} + + + +
+ { + form.setValue("pincode", data); + setIsSetPincodeOpen(false); + pincodeForm.reset(); })} - -
- + + -
+ + + -
-
- - - {t("resourceHeaderAuthProtectionDisabled")} - -
- + + -
-
-
-
+ + + + + + + + {t("resourceAuthMethods")} + + + {t("resourcePolicyAuthMethodsDescription")} + + + + + {/* Password row */} +
+
+ + + {t("resourcePasswordProtection", { + status: password + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Pincode row */} +
+
+ + + {t("resourcePincodeProtection", { + status: pincode + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Header auth row */} +
+
+ + + {headerAuth + ? t("resourceHeaderAuthProtectionEnabled") + : t("resourceHeaderAuthProtectionDisabled")} + +
+ +
+
+
+
+ ); } @@ -582,7 +746,7 @@ type PolicyOtpEmailSectionProps = { emailEnabled: boolean; }; -function PolicyOtpEmailSection({ +export function PolicyOtpEmailSection({ form, emailEnabled }: PolicyOtpEmailSectionProps) { @@ -725,7 +889,7 @@ type PolicyRulesSectionProps = { isMaxmindAsnAvailable: boolean; }; -function PolicyRulesSection({ +export function PolicyRulesSection({ form, isMaxmindAvailable, isMaxmindAsnAvailable diff --git a/src/components/resource-policy/index.ts b/src/components/resource-policy/index.ts new file mode 100644 index 000000000..7b77faddb --- /dev/null +++ b/src/components/resource-policy/index.ts @@ -0,0 +1,47 @@ +// ─── Schemas & types ────────────────────────────────────────────────────────── + +import z from "zod"; + +export const createPolicySchema = z.object({ + name: z.string().min(1).max(255), + sso: z.boolean().default(true), + skipToIdpId: z.number().nullable().optional(), + emailWhitelistEnabled: z.boolean().default(false), + roles: z.array(z.object({ id: z.string(), text: z.string() })), + users: z.array(z.object({ id: z.string(), text: z.string() })), + emails: z.array(z.object({ id: z.string(), text: z.string() })), + password: z + .object({ + password: z.string().min(4).max(100) + }) + .nullable() + .default(null), + pincode: z + .object({ + pincode: z.string().regex(/^\d{6}$/) + }) + .nullable() + .default(null), + headerAuth: z + .object({ + user: z.string().min(4).max(100), + password: z.string().min(4).max(100), + extendedCompatibility: z.boolean().default(true) + }) + .nullable() + .default(null), + applyRules: z.boolean().default(false), + rules: z + .array( + z.object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.number().int(), + enabled: z.boolean() + }) + ) + .default([]) +}); + +export type PolicyFormValues = z.infer; From ba9a0c5e3cc3b9f72ecaada46200734b1fb7b071 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 19 Feb 2026 05:23:20 +0100 Subject: [PATCH 22/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/resource-policy/CreatePolicyForm.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/resource-policy/CreatePolicyForm.tsx b/src/components/resource-policy/CreatePolicyForm.tsx index a2288268f..05e7aea99 100644 --- a/src/components/resource-policy/CreatePolicyForm.tsx +++ b/src/components/resource-policy/CreatePolicyForm.tsx @@ -85,7 +85,10 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { users: [], emails: [], applyRules: false, - rules: [] + rules: [], + password: null, + headerAuth: null, + pincode: null } }); From 267b40b73c02e9d1eb559b20a2f5dbe371f9324b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 19 Feb 2026 05:27:05 +0100 Subject: [PATCH 23/89] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../private/routers/resource/createResourcePolicy.ts | 12 ++++++++++++ server/private/routers/resource/index.ts | 1 + 2 files changed, 13 insertions(+) create mode 100644 server/private/routers/resource/createResourcePolicy.ts diff --git a/server/private/routers/resource/createResourcePolicy.ts b/server/private/routers/resource/createResourcePolicy.ts new file mode 100644 index 000000000..76d0133ef --- /dev/null +++ b/server/private/routers/resource/createResourcePolicy.ts @@ -0,0 +1,12 @@ +import { Request, Response, NextFunction } from "express"; +import z from "zod"; + +const createResourcePolicyParamsSchema = z.strictObject({ + orgId: z.string() +}); + +export async function createResourcePolicy( + req: Request, + res: Response, + next: NextFunction +) {} diff --git a/server/private/routers/resource/index.ts b/server/private/routers/resource/index.ts index 4bae8e982..0d217b671 100644 --- a/server/private/routers/resource/index.ts +++ b/server/private/routers/resource/index.ts @@ -13,3 +13,4 @@ export * from "./getMaintenanceInfo"; export * from "./listResourcePolicies"; +export * from "./createResourcePolicy"; From 0e4abdf4b6deced00e6c456d0155b4de550d35d7 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 20 Feb 2026 02:06:23 +0100 Subject: [PATCH 24/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20usewatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ResourcePolicySubForms.tsx | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/components/resource-policy/ResourcePolicySubForms.tsx b/src/components/resource-policy/ResourcePolicySubForms.tsx index bcb0f0470..3b0056ba4 100644 --- a/src/components/resource-policy/ResourcePolicySubForms.tsx +++ b/src/components/resource-policy/ResourcePolicySubForms.tsx @@ -99,7 +99,7 @@ import { import { useTranslations } from "next-intl"; import { useCallback, useMemo, useState } from "react"; -import { UseFormReturn, useForm } from "react-hook-form"; +import { UseFormReturn, useForm, useWatch } from "react-hook-form"; import z from "zod"; import type { PolicyFormValues } from "."; @@ -137,8 +137,11 @@ export function PolicyUsersRolesSection({ allIdps }: PolicyUsersRolesSectionProps) { const t = useTranslations(); - const ssoEnabled = form.watch("sso"); - const selectedIdpId = form.watch("skipToIdpId"); + const ssoEnabled = useWatch({ control: form.control, name: "sso" }); + const selectedIdpId = useWatch({ + control: form.control, + name: "skipToIdpId" + }); const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< number | null >(null); @@ -163,6 +166,7 @@ export function PolicyUsersRolesSection({ label={t("ssoUse")} defaultChecked={ssoEnabled} onCheckedChange={(val) => { + console.log(`form.setValue("sso", ${val})`); form.setValue("sso", val); }} /> @@ -555,11 +559,13 @@ export function PolicyAuthMethodsSection({
{ - form.setValue("headerAuth", data); - setIsSetHeaderAuthOpen(false); - headerAuthForm.reset(); - })} + onSubmit={headerAuthForm.handleSubmit( + (data) => { + form.setValue("headerAuth", data); + setIsSetHeaderAuthOpen(false); + headerAuthForm.reset(); + } + )} className="space-y-4" id="set-header-auth-form" > @@ -672,7 +678,9 @@ export function PolicyAuthMethodsSection({ : () => setIsSetPasswordOpen(true) } > - {password ? t("passwordRemove") : t("passwordAdd")} + {password + ? t("passwordRemove") + : t("passwordAdd")} @@ -712,8 +720,12 @@ export function PolicyAuthMethodsSection({ {headerAuth - ? t("resourceHeaderAuthProtectionEnabled") - : t("resourceHeaderAuthProtectionDisabled")} + ? t( + "resourceHeaderAuthProtectionEnabled" + ) + : t( + "resourceHeaderAuthProtectionDisabled" + )} + + + + ); +} From c5231d37f69274309e8634fff996a170cd3191ae Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 26 Feb 2026 19:20:15 +0100 Subject: [PATCH 28/89] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 + server/auth/actions.ts | 1 + server/middlewares/integration/index.ts | 1 + .../verifyApiKeyResourcePolicyAccess.ts | 92 + .../routers/policy/updateResourcePolicy.ts | 1 - server/routers/external.ts | 11 + server/routers/integration.ts | 11 +- server/routers/policy/getResourcePolicy.ts | 123 + server/routers/policy/index.ts | 1 + .../settings/(private)/policies/layout.tsx | 23 + .../policies/resource/[niceId]/page.tsx | 58 + .../{resources => resource}/create/page.tsx | 17 +- .../policies/{resources => resource}/page.tsx | 20 +- src/app/navigation.tsx | 2 +- src/components/ResourcePoliciesTable.tsx | 6 +- .../resource-policy/CreatePolicyForm.tsx | 1870 +++++++++++++++- .../resource-policy/EditPolicyForm.tsx | 1978 ++++++++++++++++- .../ResourcePolicySubForms.tsx | 58 +- src/components/resource-policy/index.ts | 18 + 19 files changed, 4177 insertions(+), 116 deletions(-) create mode 100644 server/middlewares/integration/verifyApiKeyResourcePolicyAccess.ts create mode 100644 server/routers/policy/getResourcePolicy.ts create mode 100644 server/routers/policy/index.ts create mode 100644 src/app/[orgId]/settings/(private)/policies/layout.tsx create mode 100644 src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx rename src/app/[orgId]/settings/(private)/policies/{resources => resource}/create/page.tsx (62%) rename src/app/[orgId]/settings/(private)/policies/{resources => resource}/page.tsx (83%) diff --git a/messages/en-US.json b/messages/en-US.json index fb827ffe3..78b4ad0ba 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -238,6 +238,8 @@ "rules": "Rules", "resourceSettingDescription": "Configure the settings on the resource", "resourceSetting": "{resourceName} Settings", + "resourcePolicySettingDescription": "Configure the settings on the resource policy", + "resourcePolicySetting": "{policyName} Settings", "alwaysAllow": "Bypass Auth", "alwaysDeny": "Block Access", "passToAuth": "Pass to Auth", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 6e863829e..bcb5df50c 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -133,6 +133,7 @@ export enum ActionsEnum { listApprovals = "listApprovals", updateApprovals = "updateApprovals", listResourcePolicies = "listResourcePolicies", + getResourcePolicy = "getResourcePolicy", createResourcePolicy = "createResourcePolicy", updateResourcePolicy = "updateResourcePolicy", deleteResourcePolicy = "deleteResourcePolicy", diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index 565751913..fa004bd8c 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -14,3 +14,4 @@ export * from "./verifyApiKeyApiKeyAccess"; export * from "./verifyApiKeyClientAccess"; export * from "./verifyApiKeySiteResourceAccess"; export * from "./verifyApiKeyIdpAccess"; +export * from "./verifyApiKeyResourcePolicyAccess"; diff --git a/server/middlewares/integration/verifyApiKeyResourcePolicyAccess.ts b/server/middlewares/integration/verifyApiKeyResourcePolicyAccess.ts new file mode 100644 index 000000000..2d997de53 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyResourcePolicyAccess.ts @@ -0,0 +1,92 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { resourcePolicies, apiKeyOrg } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyResourcePolicyAccess( + req: Request, + res: Response, + next: NextFunction +) { + const apiKey = req.apiKey; + const resourcePolicyId = + req.params.resourcePolicyId || + req.body.resourcePolicyId || + req.query.resourcePolicyId; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + try { + // Retrieve the resource policy + const [policy] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .limit(1); + + if (!policy) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource policy with ID ${resourcePolicyId} not found` + ) + ); + } + + if (apiKey.isRoot) { + // Root keys can access any resource policy in any org + return next(); + } + + if (!policy.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Resource policy with ID ${resourcePolicyId} does not have an organization ID` + ) + ); + } + + // Verify that the API key is linked to the resource policy's organization + if (!req.apiKeyOrg) { + const apiKeyOrgResult = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, policy.orgId) + ) + ) + .limit(1); + + if (apiKeyOrgResult.length > 0) { + req.apiKeyOrg = apiKeyOrgResult[0]; + } + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying resource policy access" + ) + ); + } +} diff --git a/server/private/routers/policy/updateResourcePolicy.ts b/server/private/routers/policy/updateResourcePolicy.ts index 58ea688cc..1f4ff5971 100644 --- a/server/private/routers/policy/updateResourcePolicy.ts +++ b/server/private/routers/policy/updateResourcePolicy.ts @@ -10,7 +10,6 @@ import { resourcePolicies, rolePolicies, userPolicies, - type ResourcePolicy, type ResourcePolicy } from "@server/db"; import { and, eq } from "drizzle-orm"; diff --git a/server/routers/external.ts b/server/routers/external.ts index c69fdacc5..67494c643 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -3,6 +3,7 @@ import config from "@server/lib/config"; import * as site from "./site"; import * as org from "./org"; import * as resource from "./resource"; +import * as policy from "./policy"; import * as domain from "./domain"; import * as target from "./target"; import * as user from "./user"; @@ -521,6 +522,7 @@ authenticated.get( verifyUserHasAction(ActionsEnum.getResource), resource.getResource ); + authenticated.post( "/resource/:resourceId", verifyResourceAccess, @@ -627,6 +629,15 @@ authenticated.post( logActionAudit(ActionsEnum.updateRole), role.updateRole ); + +authenticated.get( + "/org/:orgId/resource-policy/:niceId", + verifyOrgAccess, + verifyResourcePolicyAccess, + verifyUserHasAction(ActionsEnum.getResourcePolicy), + policy.getResourcePolicy +); + // authenticated.get( // "/role/:roleId", // verifyRoleAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 6c39fe983..e52e710ee 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -2,6 +2,7 @@ import * as site from "./site"; import * as org from "./org"; import * as blueprints from "./blueprints"; import * as resource from "./resource"; +import * as policy from "./policy"; import * as domain from "./domain"; import * as target from "./target"; import * as user from "./user"; @@ -27,7 +28,8 @@ import { verifyApiKeyClientAccess, verifyApiKeySiteResourceAccess, verifyApiKeySetResourceClients, - verifyLimits + verifyLimits, + verifyApiKeyResourcePolicyAccess } from "@server/middlewares"; import HttpCode from "@server/types/HttpCode"; import { Router } from "express"; @@ -392,6 +394,13 @@ authenticated.get( resource.getResource ); +authenticated.get( + "/resource-policy/:resourcePolicyId", + verifyApiKeyResourcePolicyAccess, + verifyApiKeyHasAction(ActionsEnum.getResourcePolicy), + policy.getResourcePolicy +); + authenticated.post( "/resource/:resourceId", verifyApiKeyResourceAccess, diff --git a/server/routers/policy/getResourcePolicy.ts b/server/routers/policy/getResourcePolicy.ts new file mode 100644 index 000000000..0d33a222e --- /dev/null +++ b/server/routers/policy/getResourcePolicy.ts @@ -0,0 +1,123 @@ +import { db, resourcePolicies, rolePolicies, userPolicies } from "@server/db"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq, type SQL } from "drizzle-orm"; +import type { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import z from "zod"; +import { fromError } from "zod-validation-error"; + +const getResourcePolicySchema = z + .strictObject({ + niceId: z.string(), + orgId: z.string() + }) + .or( + z.strictObject({ + resourcePolicyId: z.coerce.number().int().positive() + }) + ); + +async function query(params: z.infer) { + const conditions: SQL[] = []; + if ("resourcePolicyId" in params) { + conditions.push( + eq(resourcePolicies.resourcePolicyId, params.resourcePolicyId) + ); + } else { + conditions.push( + eq(resourcePolicies.niceId, params.niceId), + eq(resourcePolicies.orgId, params.orgId) + ); + } + + const [res] = await db + .select({ + policy: resourcePolicies, + userPolicies, + rolePolicies + }) + .from(resourcePolicies) + .leftJoin( + userPolicies, + eq(userPolicies.resourcePolicyId, resourcePolicies.resourcePolicyId) + ) + .leftJoin( + rolePolicies, + eq(rolePolicies.resourcePolicyId, resourcePolicies.resourcePolicyId) + ) + .where(and(...conditions)) + .limit(1); + return res; +} + +export type GetResourcePolicyResponse = Awaited>; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/resource-policy/{niceId}", + description: + "Get a resource policy by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.", + tags: [OpenAPITags.Org, OpenAPITags.Policy], + request: { + params: z.object({ + orgId: z.string(), + niceId: z.string() + }) + }, + responses: {} +}); + +registry.registerPath({ + method: "get", + path: "/resource-policy/{resourcePolicyId}", + description: "Get a resource policy by its resourcePolicyId.", + tags: [OpenAPITags.Policy], + request: { + params: z.object({ + resourcePolicyId: z.number() + }) + }, + responses: {} +}); + +export async function getResourcePolicy( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getResourcePolicySchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const policy = await query(parsedParams.data); + + if (!policy) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource policy not found") + ); + } + + return response(res, { + data: policy, + success: true, + error: false, + message: "Resource Policy retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/policy/index.ts b/server/routers/policy/index.ts new file mode 100644 index 000000000..a05c292ee --- /dev/null +++ b/server/routers/policy/index.ts @@ -0,0 +1 @@ +export * from "./getResourcePolicy"; diff --git a/src/app/[orgId]/settings/(private)/policies/layout.tsx b/src/app/[orgId]/settings/(private)/policies/layout.tsx new file mode 100644 index 000000000..ef5803e1a --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/layout.tsx @@ -0,0 +1,23 @@ +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import OrgProvider from "@app/providers/OrgProvider"; +import type { GetOrgResponse } from "@server/routers/org"; +import { redirect } from "next/navigation"; + +export interface PolicyLayoutPageProps { + params: Promise<{ orgId: string }>; + children: React.ReactNode; +} + +export default async function PolicyLayoutPage(props: PolicyLayoutPageProps) { + const params = await props.params; + + let org: GetOrgResponse | null = null; + try { + const res = await getCachedOrg(params.orgId); + org = res.data.data; + } catch { + redirect(`/${params.orgId}/settings`); + } + + return {props.children}; +} diff --git a/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx b/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx new file mode 100644 index 000000000..61833a1f1 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx @@ -0,0 +1,58 @@ +import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { Button } from "@app/components/ui/button"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import type { ResourcePolicy } from "@server/db"; +import type { GetResourcePolicyResponse } from "@server/routers/policy"; +import type { AxiosResponse } from "axios"; +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +export interface EditPolicyPageProps { + params: Promise<{ niceId: string; orgId: string }>; +} + +export default async function EditPolicyPage(props: EditPolicyPageProps) { + const params = await props.params; + const t = await getTranslations(); + + let policy: ResourcePolicy | null = null; + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/resource-policy/${params.niceId}`, + await authCookieHeader() + ); + policy = res.data.data.policy; + } catch { + redirect(`/${params.orgId}/settings/policies/resource`); + } + + if (!policy) { + redirect(`/${params.orgId}/settings/policies/resource`); + } + + return ( + <> +
+ + + +
+ + + + ); +} diff --git a/src/app/[orgId]/settings/(private)/policies/resources/create/page.tsx b/src/app/[orgId]/settings/(private)/policies/resource/create/page.tsx similarity index 62% rename from src/app/[orgId]/settings/(private)/policies/resources/create/page.tsx rename to src/app/[orgId]/settings/(private)/policies/resource/create/page.tsx index 6efdc5597..edf67fbef 100644 --- a/src/app/[orgId]/settings/(private)/policies/resources/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/policies/resource/create/page.tsx @@ -1,12 +1,8 @@ import { CreatePolicyForm } from "@app/components/resource-policy/CreatePolicyForm"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { Button } from "@app/components/ui/button"; -import { getCachedOrg } from "@app/lib/api/getCachedOrg"; -import OrgProvider from "@app/providers/OrgProvider"; -import type { GetOrgResponse } from "@server/routers/org"; import { getTranslations } from "next-intl/server"; import Link from "next/link"; -import { redirect } from "next/navigation"; export interface CreateResourcePolicyPageProps { params: Promise<{ orgId: string }>; @@ -18,13 +14,6 @@ export default async function CreateResourcePolicyPage( const params = await props.params; const t = await getTranslations(); - let org: GetOrgResponse | null = null; - try { - const res = await getCachedOrg(params.orgId); - org = res.data.data; - } catch { - redirect(`/${params.orgId}/settings/resources`); - } return ( <>
@@ -34,15 +23,13 @@ export default async function CreateResourcePolicyPage( />
- - - + ); } diff --git a/src/app/[orgId]/settings/(private)/policies/resources/page.tsx b/src/app/[orgId]/settings/(private)/policies/resource/page.tsx similarity index 83% rename from src/app/[orgId]/settings/(private)/policies/resources/page.tsx rename to src/app/[orgId]/settings/(private)/policies/resource/page.tsx index e641696ef..3f2ec53b0 100644 --- a/src/app/[orgId]/settings/(private)/policies/resources/page.tsx +++ b/src/app/[orgId]/settings/(private)/policies/resource/page.tsx @@ -55,17 +55,15 @@ export default async function ResourcePoliciesPage( description={t("resourcePoliciesDescription")} /> - - - + ); } diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index bf68837f5..324f051c3 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -132,7 +132,7 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [ items: [ { title: "sidebarResourcePolicies", - href: "/{orgId}/settings/policies/resources", + href: "/{orgId}/settings/policies/resource", icon: ( ) diff --git a/src/components/ResourcePoliciesTable.tsx b/src/components/ResourcePoliciesTable.tsx index 32860a464..69dee6963 100644 --- a/src/components/ResourcePoliciesTable.tsx +++ b/src/components/ResourcePoliciesTable.tsx @@ -103,7 +103,7 @@ export function ResourcePoliciesTable({ {t("viewSettings")} @@ -122,7 +122,7 @@ export function ResourcePoliciesTable({ +
+ + ); + } + + return ( + <> + {/* Password Credenza */} + { + setIsSetPasswordOpen(val); + if (!val) passwordForm.reset(); + }} + > + + + + {t("resourcePasswordSetupTitle")} + + + {t("resourcePasswordSetupTitleDescription")} + + + +
+ { + form.setValue("password", data); + setIsSetPasswordOpen(false); + passwordForm.reset(); + })} + className="space-y-4" + id="set-password-form" + > + ( + + + {t("password")} + + + + + + + )} + /> + + +
+ + + + + + +
+
+ + {/* Pincode Credenza */} + { + setIsSetPincodeOpen(val); + if (!val) pincodeForm.reset(); + }} + > + + + + {t("resourcePincodeSetupTitle")} + + + {t("resourcePincodeSetupTitleDescription")} + + + +
+ { + form.setValue("pincode", data); + setIsSetPincodeOpen(false); + pincodeForm.reset(); + })} + className="space-y-4" + id="set-pincode-form" + > + ( + + + {t("resourcePincode")} + + +
+ + + + + + + + + + +
+
+ +
+ )} + /> + + +
+ + + + + + +
+
+ + {/* Header Auth Credenza */} + { + setIsSetHeaderAuthOpen(val); + if (!val) headerAuthForm.reset(); + }} + > + + + + {t("resourceHeaderAuthSetupTitle")} + + + {t("resourceHeaderAuthSetupTitleDescription")} + + + +
+ { + form.setValue("headerAuth", data); + setIsSetHeaderAuthOpen(false); + headerAuthForm.reset(); + } + )} + className="space-y-4" + id="set-header-auth-form" + > + ( + + {t("user")} + + + + + + )} + /> + ( + + + {t("password")} + + + + + + + )} + /> + ( + + + + + + + )} + /> + + +
+ + + + + + +
+
+ + + + + {t("resourceAuthMethods")} + + + {t("resourcePolicyAuthMethodsDescription")} + + + + + {/* Password row */} +
+
+ + + {t("resourcePasswordProtection", { + status: password + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Pincode row */} +
+
+ + + {t("resourcePincodeProtection", { + status: pincode + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Header auth row */} +
+
+ + + {headerAuth + ? t( + "resourceHeaderAuthProtectionEnabled" + ) + : t( + "resourceHeaderAuthProtectionDisabled" + )} + +
+ +
+
+
+
+ + ); +} + +// ─── PolicyOtpEmailSection ──────────────────────────────────────────────────── + +type PolicyOtpEmailSectionProps = { + form: UseFormReturn; + emailEnabled: boolean; +}; + +export function PolicyOtpEmailSection({ + form, + emailEnabled +}: PolicyOtpEmailSectionProps) { + const t = useTranslations(); + const [isOpen, setIsOpen] = useState(false); + const [whitelistEnabled, setWhitelistEnabled] = useState(false); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + + if (!isOpen) { + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + + + ); + } + + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + {!emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + { + setWhitelistEnabled(val); + form.setValue("emailWhitelistEnabled", val); + }} + disabled={!emailEnabled} + /> + + {whitelistEnabled && emailEnabled && ( + ( + + + + + + {/* @ts-ignore */} + { + return z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: t( + "otpEmailErrorInvalid" + ) + } + ) + ) + .safeParse(tag).success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder={t("otpEmailEnter")} + tags={form.getValues().emails} + setTags={(newEmails) => { + form.setValue( + "emails", + newEmails as [Tag, ...Tag[]] + ); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t("otpEmailEnterDescription")} + + + )} + /> + )} + + + + ); +} + +// ─── PolicyRulesSection ─────────────────────────────────────────────────────── + +const addRuleSchema = z.object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.coerce.number().int().optional() +}); + +type LocalRule = { + ruleId: number; + action: "ACCEPT" | "DROP" | "PASS"; + match: string; + value: string; + priority: number; + enabled: boolean; + new?: boolean; + updated?: boolean; +}; + +type PolicyRulesSectionProps = { + form: UseFormReturn; + isMaxmindAvailable: boolean; + isMaxmindAsnAvailable: boolean; +}; + +export function PolicyRulesSection({ + form, + isMaxmindAvailable, + isMaxmindAsnAvailable +}: PolicyRulesSectionProps) { + const t = useTranslations(); + const [isOpen, setIsOpen] = useState(false); + const [rules, setRules] = useState([]); + const [rulesEnabled, setRulesEnabled] = useState(false); + const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = + useState(false); + const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); + + const addRuleForm = useForm({ + resolver: zodResolver(addRuleSchema), + defaultValues: { + action: "ACCEPT" as const, + match: "IP", + value: "" + } + }); + + const RuleAction = useMemo( + () => ({ + ACCEPT: t("alwaysAllow"), + DROP: t("alwaysDeny"), + PASS: t("passToAuth") + }), + [t] + ); + + const RuleMatch = useMemo( + () => ({ + PATH: t("path"), + IP: "IP", + CIDR: t("ipAddressRange"), + COUNTRY: t("country"), + ASN: "ASN" + }), + [t] + ); + + const syncFormRules = useCallback( + (updatedRules: LocalRule[]) => { + form.setValue( + "rules", + updatedRules.map( + ({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + }) + ) + ); + }, + [form] + ); + + const addRule = useCallback( + function addRule(data: z.infer) { + const isDuplicate = rules.some( + (rule) => + rule.action === data.action && + rule.match === data.match && + rule.value === data.value + ); + if (isDuplicate) { + toast({ + variant: "destructive", + title: t("rulesErrorDuplicate"), + description: t("rulesErrorDuplicateDescription") + }); + return; + } + if (data.match === "CIDR" && !isValidCIDR(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddressRange"), + description: t("rulesErrorInvalidIpAddressRangeDescription") + }); + return; + } + if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidUrl"), + description: t("rulesErrorInvalidUrlDescription") + }); + return; + } + if (data.match === "IP" && !isValidIP(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddress"), + description: t("rulesErrorInvalidIpAddressDescription") + }); + return; + } + if ( + data.match === "COUNTRY" && + !COUNTRIES.some((c) => c.code === data.value) + ) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidCountry"), + description: t("rulesErrorInvalidCountryDescription") || "" + }); + return; + } + + let priority = data.priority; + if (priority === undefined) { + priority = + rules.reduce( + (acc, rule) => + rule.priority > acc ? rule.priority : acc, + 0 + ) + 1; + } + + const updatedRules = [ + ...rules, + { + ...data, + ruleId: new Date().getTime(), + new: true, + priority, + enabled: true + } + ]; + setRules(updatedRules); + syncFormRules(updatedRules); + addRuleForm.reset(); + }, + [rules, t, addRuleForm, syncFormRules] + ); + + const removeRule = useCallback( + function removeRule(ruleId: number) { + const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules] + ); + + const updateRule = useCallback( + function updateRule(ruleId: number, data: Partial) { + const updatedRules = rules.map((rule) => + rule.ruleId === ruleId + ? { ...rule, ...data, updated: true } + : rule + ); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules] + ); + + const getValueHelpText = useCallback( + function getValueHelpText(type: string) { + switch (type) { + case "CIDR": + return t("rulesMatchIpAddressRangeDescription"); + case "IP": + return t("rulesMatchIpAddress"); + case "PATH": + return t("rulesMatchUrl"); + case "COUNTRY": + return t("rulesMatchCountry"); + case "ASN": + return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; + } + }, + [t] + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + accessorKey: "priority", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + e.currentTarget.focus()} + onBlur={(e) => { + const parsed = z.coerce + .number() + .int() + .optional() + .safeParse(e.target.value); + if (!parsed.success) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidPriority"), + description: t( + "rulesErrorInvalidPriorityDescription" + ) + }); + return; + } + updateRule(row.original.ruleId, { + priority: parsed.data + }); + }} + /> + ) + }, + { + accessorKey: "action", + header: () => {t("rulesAction")}, + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "match", + header: () => ( + {t("rulesMatchType")} + ), + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "value", + header: () => {t("value")}, + cell: ({ row }) => + row.original.match === "COUNTRY" ? ( + + + + + + + + + + {t("noCountryFound")} + + + {COUNTRIES.map((country) => ( + + updateRule( + row.original.ruleId, + { + value: country.code + } + ) + } + > + + {country.name} ( + {country.code}) + + ))} + + + + + + ) : row.original.match === "ASN" ? ( + + + + + + + + + + No ASN found. Enter a custom ASN + below. + + + {MAJOR_ASNS.map((asn) => ( + + updateRule( + row.original.ruleId, + { value: asn.code } + ) + } + > + + {asn.name} ({asn.code}) + + ))} + + + +
+ + asn.code === + row.original.value + ) + ? row.original.value + : "" + } + onKeyDown={(e) => { + if (e.key === "Enter") { + const value = + e.currentTarget.value + .toUpperCase() + .replace(/^AS/, ""); + if (/^\d+$/.test(value)) { + updateRule( + row.original.ruleId, + { value: "AS" + value } + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + updateRule(row.original.ruleId, { + value: e.target.value + }) + } + /> + ) + }, + { + accessorKey: "enabled", + header: () => {t("enabled")}, + cell: ({ row }) => ( + + updateRule(row.original.ruleId, { enabled: val }) + } + /> + ) + }, + { + id: "actions", + header: () => {t("actions")}, + cell: ({ row }) => ( +
+ +
+ ) + } + ], + [ + t, + RuleAction, + RuleMatch, + isMaxmindAvailable, + isMaxmindAsnAvailable, + updateRule, + removeRule + ] + ); + + const table = useReactTable({ + data: rules, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { pagination: { pageIndex: 0, pageSize: 1000 } } + }); + + if (!isOpen) { + return ( + + + + {t("rulesResource")} + + + {t("rulesResourcePolicyDescription")} + + + + + + + ); + } + + return ( + + + + {t("rulesResource")} + + + {t("rulesResourceDescription")} + + + +
+
+ { + setRulesEnabled(val); + form.setValue("applyRules", val); + }} + /> +
+ +
+ +
+ ( + + + {t("rulesAction")} + + + + + + + )} + /> + ( + + + {t("rulesMatchType")} + + + + + + + )} + /> + ( + + + + {addRuleForm.watch("match") === + "COUNTRY" ? ( + + + + + + + + + + {t( + "noCountryFound" + )} + + + {COUNTRIES.map( + ( + country + ) => ( + { + field.onChange( + country.code + ); + setOpenAddRuleCountrySelect( + false + ); + }} + > + + { + country.name + }{" "} + ( + { + country.code + } + + ) + + ) + )} + + + + + + ) : addRuleForm.watch( + "match" + ) === "ASN" ? ( + + + + + + + + + + No ASN + found. + Use the + custom + input + below. + + + {MAJOR_ASNS.map( + ( + asn + ) => ( + { + field.onChange( + asn.code + ); + setOpenAddRuleAsnSelect( + false + ); + }} + > + + { + asn.name + }{" "} + ( + { + asn.code + } + + ) + + ) + )} + + + +
+ { + if ( + e.key === + "Enter" + ) { + const value = + e.currentTarget.value + .toUpperCase() + .replace( + /^AS/, + "" + ); + if ( + /^\d+$/.test( + value + ) + ) { + field.onChange( + "AS" + + value + ); + setOpenAddRuleAsnSelect( + false + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + )} +
+ +
+ )} + /> + +
+
+ + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isActionsColumn = + header.column.id === "actions"; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column + .columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const isActionsColumn = + cell.column.id === "actions"; + return ( + + {flexRender( + cell.column.columnDef + .cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + + {t("rulesNoOne")} + + + )} + +
+
+
+
+ ); +} diff --git a/src/components/resource-policy/EditPolicyForm.tsx b/src/components/resource-policy/EditPolicyForm.tsx index 3f09f7ef8..4331fb286 100644 --- a/src/components/resource-policy/EditPolicyForm.tsx +++ b/src/components/resource-policy/EditPolicyForm.tsx @@ -9,16 +9,7 @@ import { SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; -import { Button } from "@app/components/ui/button"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; + import { useEnvContext } from "@app/hooks/useEnvContext"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; @@ -32,15 +23,8 @@ import { UserType } from "@server/types/UserTypes"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { useActionState, useMemo } from "react"; -import { useForm } from "react-hook-form"; import z from "zod"; -import { - PolicyAuthMethodsSection, - PolicyOtpEmailSection, - PolicyRulesSection, - PolicyUsersRolesSection -} from "./ResourcePolicySubForms"; + import { type PolicyFormValues, createPolicySchema } from "."; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; @@ -48,13 +32,107 @@ import { orgs, type ResourcePolicy } from "@server/db"; import type { AxiosResponse } from "axios"; import { useRouter } from "next/navigation"; -// ─── CreatePolicyForm ───────────────────────────────────────────────────────── +import { SwitchInput } from "@app/components/SwitchInput"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { Input } from "@app/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { Switch } from "@app/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot +} from "@app/components/ui/input-otp"; + +import { MAJOR_ASNS } from "@server/db/asns"; +import { COUNTRIES } from "@server/db/countries"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable +} from "@tanstack/react-table"; +import { + ArrowUpDown, + Binary, + Bot, + Check, + ChevronsUpDown, + InfoIcon, + Key, + Plus +} from "lucide-react"; + +import { useCallback, useMemo, useState, useActionState } from "react"; +import { UseFormReturn, useForm, useWatch } from "react-hook-form"; + +// ─── EditPolicyForm ───────────────────────────────────────────────────────── export type EditPolicyFormProps = { policy: ResourcePolicy; + hidePolicyNameForm?: boolean; }; -export function EditPolicyForm({}: EditPolicyFormProps) { +export function EditPolicyForm({ + hidePolicyNameForm, + policy +}: EditPolicyFormProps) { const { org } = useOrgContext(); const t = useTranslations(); const { env } = useEnvContext(); @@ -87,7 +165,7 @@ export function EditPolicyForm({}: EditPolicyFormProps) { const form = useForm({ resolver: zodResolver(createPolicySchema) as any, defaultValues: { - name: "", + name: policy.name, sso: true, skipToIdpId: null, emailWhitelistEnabled: false, @@ -197,39 +275,7 @@ export function EditPolicyForm({}: EditPolicyFormProps) {
{/* Name */} - - - - {t("resourcePolicyName")} - - - {t("resourcePolicyNameDescription")} - - - - - ( - - {t("name")} - - - - - - )} - /> - - - - + {!hidePolicyNameForm && } - -
- -
); } + +// ─── PolicyNameSection ────────────────────────────────────────────────── +type PolicyNameSectionProps = { + form: UseFormReturn; + isEditing?: boolean; +}; + +export function PolicyNameSection({ form }: PolicyNameSectionProps) { + const t = useTranslations(); + return ( + + + + {t("resourcePolicyName")} + + + {t("resourcePolicyNameDescription")} + + + + + ( + + {t("name")} + + + + + + )} + /> + + + +
+ +
+
+ ); +} + +// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── + +type PolicyUsersRolesSectionProps = { + form: UseFormReturn; + allRoles: { id: string; text: string }[]; + allUsers: { id: string; text: string }[]; + allIdps: { id: number; text: string }[]; +}; + +export function PolicyUsersRolesSection({ + form, + allRoles, + allUsers, + allIdps +}: PolicyUsersRolesSectionProps) { + const t = useTranslations(); + const ssoEnabled = useWatch({ control: form.control, name: "sso" }); + const selectedIdpId = useWatch({ + control: form.control, + name: "skipToIdpId" + }); + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< + number | null + >(null); + const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< + number | null + >(null); + + return ( + + + + {t("resourceUsersRoles")} + + + {t("resourcePolicyUsersRolesDescription")} + + + + + { + console.log(`form.setValue("sso", ${val})`); + form.setValue("sso", val); + }} + /> + + {ssoEnabled && ( + <> + ( + + {t("roles")} + + { + form.setValue( + "roles", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allRoles} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + {t("resourceRoleDescription")} + + + )} + /> + ( + + {t("users")} + + { + form.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allUsers} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + + )} + + {ssoEnabled && allIdps.length > 0 && ( +
+ + +

+ {t("defaultIdentityProviderDescription")} +

+
+ )} +
+
+
+ ); +} + +// ─── PolicyAuthMethodsSection ───────────────────────────────────────────────── + +const setPasswordSchema = z.object({ + password: z.string().min(4).max(100) +}); + +const setPincodeSchema = z.object({ + pincode: z.string().length(6) +}); + +const setHeaderAuthSchema = z.object({ + user: z.string().min(4).max(100), + password: z.string().min(4).max(100), + extendedCompatibility: z.boolean() +}); + +type PolicyAuthMethodsSectionProps = { + form: UseFormReturn; +}; + +export function PolicyAuthMethodsSection({ + form +}: PolicyAuthMethodsSectionProps) { + const t = useTranslations(); + const [isOpen, setIsOpen] = useState(false); + const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); + const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); + const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); + + const password = form.watch("password"); + const pincode = form.watch("pincode"); + const headerAuth = form.watch("headerAuth"); + + const passwordForm = useForm({ + resolver: zodResolver(setPasswordSchema), + defaultValues: { password: "" } + }); + + const pincodeForm = useForm({ + resolver: zodResolver(setPincodeSchema), + defaultValues: { pincode: "" } + }); + + const headerAuthForm = useForm({ + resolver: zodResolver(setHeaderAuthSchema), + defaultValues: { user: "", password: "", extendedCompatibility: true } + }); + + if (!isOpen) { + return ( + + + + {t("resourceAuthMethods")} + + + {t("resourcePolicyAuthMethodsDescription")} + + + + + + + ); + } + + return ( + <> + {/* Password Credenza */} + { + setIsSetPasswordOpen(val); + if (!val) passwordForm.reset(); + }} + > + + + + {t("resourcePasswordSetupTitle")} + + + {t("resourcePasswordSetupTitleDescription")} + + + +
+ { + form.setValue("password", data); + setIsSetPasswordOpen(false); + passwordForm.reset(); + })} + className="space-y-4" + id="set-password-form" + > + ( + + + {t("password")} + + + + + + + )} + /> + + +
+ + + + + + +
+
+ + {/* Pincode Credenza */} + { + setIsSetPincodeOpen(val); + if (!val) pincodeForm.reset(); + }} + > + + + + {t("resourcePincodeSetupTitle")} + + + {t("resourcePincodeSetupTitleDescription")} + + + +
+ { + form.setValue("pincode", data); + setIsSetPincodeOpen(false); + pincodeForm.reset(); + })} + className="space-y-4" + id="set-pincode-form" + > + ( + + + {t("resourcePincode")} + + +
+ + + + + + + + + + +
+
+ +
+ )} + /> + + +
+ + + + + + +
+
+ + {/* Header Auth Credenza */} + { + setIsSetHeaderAuthOpen(val); + if (!val) headerAuthForm.reset(); + }} + > + + + + {t("resourceHeaderAuthSetupTitle")} + + + {t("resourceHeaderAuthSetupTitleDescription")} + + + +
+ { + form.setValue("headerAuth", data); + setIsSetHeaderAuthOpen(false); + headerAuthForm.reset(); + } + )} + className="space-y-4" + id="set-header-auth-form" + > + ( + + {t("user")} + + + + + + )} + /> + ( + + + {t("password")} + + + + + + + )} + /> + ( + + + + + + + )} + /> + + +
+ + + + + + +
+
+ + + + + {t("resourceAuthMethods")} + + + {t("resourcePolicyAuthMethodsDescription")} + + + + + {/* Password row */} +
+
+ + + {t("resourcePasswordProtection", { + status: password + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Pincode row */} +
+
+ + + {t("resourcePincodeProtection", { + status: pincode + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Header auth row */} +
+
+ + + {headerAuth + ? t( + "resourceHeaderAuthProtectionEnabled" + ) + : t( + "resourceHeaderAuthProtectionDisabled" + )} + +
+ +
+
+
+
+ + ); +} + +// ─── PolicyOtpEmailSection ──────────────────────────────────────────────────── + +type PolicyOtpEmailSectionProps = { + form: UseFormReturn; + emailEnabled: boolean; +}; + +export function PolicyOtpEmailSection({ + form, + emailEnabled +}: PolicyOtpEmailSectionProps) { + const t = useTranslations(); + const [isOpen, setIsOpen] = useState(false); + const [whitelistEnabled, setWhitelistEnabled] = useState(false); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + + if (!isOpen) { + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + + + ); + } + + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + {!emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + { + setWhitelistEnabled(val); + form.setValue("emailWhitelistEnabled", val); + }} + disabled={!emailEnabled} + /> + + {whitelistEnabled && emailEnabled && ( + ( + + + + + + {/* @ts-ignore */} + { + return z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: t( + "otpEmailErrorInvalid" + ) + } + ) + ) + .safeParse(tag).success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder={t("otpEmailEnter")} + tags={form.getValues().emails} + setTags={(newEmails) => { + form.setValue( + "emails", + newEmails as [Tag, ...Tag[]] + ); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t("otpEmailEnterDescription")} + + + )} + /> + )} + + + + ); +} + +// ─── PolicyRulesSection ─────────────────────────────────────────────────────── + +const addRuleSchema = z.object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.coerce.number().int().optional() +}); + +type LocalRule = { + ruleId: number; + action: "ACCEPT" | "DROP" | "PASS"; + match: string; + value: string; + priority: number; + enabled: boolean; + new?: boolean; + updated?: boolean; +}; + +type PolicyRulesSectionProps = { + form: UseFormReturn; + isMaxmindAvailable: boolean; + isMaxmindAsnAvailable: boolean; +}; + +export function PolicyRulesSection({ + form, + isMaxmindAvailable, + isMaxmindAsnAvailable +}: PolicyRulesSectionProps) { + const t = useTranslations(); + const [isOpen, setIsOpen] = useState(false); + const [rules, setRules] = useState([]); + const [rulesEnabled, setRulesEnabled] = useState(false); + const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = + useState(false); + const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); + + const addRuleForm = useForm({ + resolver: zodResolver(addRuleSchema), + defaultValues: { + action: "ACCEPT" as const, + match: "IP", + value: "" + } + }); + + const RuleAction = useMemo( + () => ({ + ACCEPT: t("alwaysAllow"), + DROP: t("alwaysDeny"), + PASS: t("passToAuth") + }), + [t] + ); + + const RuleMatch = useMemo( + () => ({ + PATH: t("path"), + IP: "IP", + CIDR: t("ipAddressRange"), + COUNTRY: t("country"), + ASN: "ASN" + }), + [t] + ); + + const syncFormRules = useCallback( + (updatedRules: LocalRule[]) => { + form.setValue( + "rules", + updatedRules.map( + ({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + }) + ) + ); + }, + [form] + ); + + const addRule = useCallback( + function addRule(data: z.infer) { + const isDuplicate = rules.some( + (rule) => + rule.action === data.action && + rule.match === data.match && + rule.value === data.value + ); + if (isDuplicate) { + toast({ + variant: "destructive", + title: t("rulesErrorDuplicate"), + description: t("rulesErrorDuplicateDescription") + }); + return; + } + if (data.match === "CIDR" && !isValidCIDR(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddressRange"), + description: t("rulesErrorInvalidIpAddressRangeDescription") + }); + return; + } + if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidUrl"), + description: t("rulesErrorInvalidUrlDescription") + }); + return; + } + if (data.match === "IP" && !isValidIP(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddress"), + description: t("rulesErrorInvalidIpAddressDescription") + }); + return; + } + if ( + data.match === "COUNTRY" && + !COUNTRIES.some((c) => c.code === data.value) + ) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidCountry"), + description: t("rulesErrorInvalidCountryDescription") || "" + }); + return; + } + + let priority = data.priority; + if (priority === undefined) { + priority = + rules.reduce( + (acc, rule) => + rule.priority > acc ? rule.priority : acc, + 0 + ) + 1; + } + + const updatedRules = [ + ...rules, + { + ...data, + ruleId: new Date().getTime(), + new: true, + priority, + enabled: true + } + ]; + setRules(updatedRules); + syncFormRules(updatedRules); + addRuleForm.reset(); + }, + [rules, t, addRuleForm, syncFormRules] + ); + + const removeRule = useCallback( + function removeRule(ruleId: number) { + const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules] + ); + + const updateRule = useCallback( + function updateRule(ruleId: number, data: Partial) { + const updatedRules = rules.map((rule) => + rule.ruleId === ruleId + ? { ...rule, ...data, updated: true } + : rule + ); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules] + ); + + const getValueHelpText = useCallback( + function getValueHelpText(type: string) { + switch (type) { + case "CIDR": + return t("rulesMatchIpAddressRangeDescription"); + case "IP": + return t("rulesMatchIpAddress"); + case "PATH": + return t("rulesMatchUrl"); + case "COUNTRY": + return t("rulesMatchCountry"); + case "ASN": + return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; + } + }, + [t] + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + accessorKey: "priority", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + e.currentTarget.focus()} + onBlur={(e) => { + const parsed = z.coerce + .number() + .int() + .optional() + .safeParse(e.target.value); + if (!parsed.success) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidPriority"), + description: t( + "rulesErrorInvalidPriorityDescription" + ) + }); + return; + } + updateRule(row.original.ruleId, { + priority: parsed.data + }); + }} + /> + ) + }, + { + accessorKey: "action", + header: () => {t("rulesAction")}, + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "match", + header: () => ( + {t("rulesMatchType")} + ), + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "value", + header: () => {t("value")}, + cell: ({ row }) => + row.original.match === "COUNTRY" ? ( + + + + + + + + + + {t("noCountryFound")} + + + {COUNTRIES.map((country) => ( + + updateRule( + row.original.ruleId, + { + value: country.code + } + ) + } + > + + {country.name} ( + {country.code}) + + ))} + + + + + + ) : row.original.match === "ASN" ? ( + + + + + + + + + + No ASN found. Enter a custom ASN + below. + + + {MAJOR_ASNS.map((asn) => ( + + updateRule( + row.original.ruleId, + { value: asn.code } + ) + } + > + + {asn.name} ({asn.code}) + + ))} + + + +
+ + asn.code === + row.original.value + ) + ? row.original.value + : "" + } + onKeyDown={(e) => { + if (e.key === "Enter") { + const value = + e.currentTarget.value + .toUpperCase() + .replace(/^AS/, ""); + if (/^\d+$/.test(value)) { + updateRule( + row.original.ruleId, + { value: "AS" + value } + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + updateRule(row.original.ruleId, { + value: e.target.value + }) + } + /> + ) + }, + { + accessorKey: "enabled", + header: () => {t("enabled")}, + cell: ({ row }) => ( + + updateRule(row.original.ruleId, { enabled: val }) + } + /> + ) + }, + { + id: "actions", + header: () => {t("actions")}, + cell: ({ row }) => ( +
+ +
+ ) + } + ], + [ + t, + RuleAction, + RuleMatch, + isMaxmindAvailable, + isMaxmindAsnAvailable, + updateRule, + removeRule + ] + ); + + const table = useReactTable({ + data: rules, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { pagination: { pageIndex: 0, pageSize: 1000 } } + }); + + if (!isOpen) { + return ( + + + + {t("rulesResource")} + + + {t("rulesResourcePolicyDescription")} + + + + + + + ); + } + + return ( + + + + {t("rulesResource")} + + + {t("rulesResourceDescription")} + + + +
+
+ { + setRulesEnabled(val); + form.setValue("applyRules", val); + }} + /> +
+ +
+ +
+ ( + + + {t("rulesAction")} + + + + + + + )} + /> + ( + + + {t("rulesMatchType")} + + + + + + + )} + /> + ( + + + + {addRuleForm.watch("match") === + "COUNTRY" ? ( + + + + + + + + + + {t( + "noCountryFound" + )} + + + {COUNTRIES.map( + ( + country + ) => ( + { + field.onChange( + country.code + ); + setOpenAddRuleCountrySelect( + false + ); + }} + > + + { + country.name + }{" "} + ( + { + country.code + } + + ) + + ) + )} + + + + + + ) : addRuleForm.watch( + "match" + ) === "ASN" ? ( + + + + + + + + + + No ASN + found. + Use the + custom + input + below. + + + {MAJOR_ASNS.map( + ( + asn + ) => ( + { + field.onChange( + asn.code + ); + setOpenAddRuleAsnSelect( + false + ); + }} + > + + { + asn.name + }{" "} + ( + { + asn.code + } + + ) + + ) + )} + + + +
+ { + if ( + e.key === + "Enter" + ) { + const value = + e.currentTarget.value + .toUpperCase() + .replace( + /^AS/, + "" + ); + if ( + /^\d+$/.test( + value + ) + ) { + field.onChange( + "AS" + + value + ); + setOpenAddRuleAsnSelect( + false + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + )} +
+ +
+ )} + /> + +
+
+ + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isActionsColumn = + header.column.id === "actions"; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column + .columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const isActionsColumn = + cell.column.id === "actions"; + return ( + + {flexRender( + cell.column.columnDef + .cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + + {t("rulesNoOne")} + + + )} + +
+
+
+
+ ); +} diff --git a/src/components/resource-policy/ResourcePolicySubForms.tsx b/src/components/resource-policy/ResourcePolicySubForms.tsx index 3b0056ba4..37a83b3fe 100644 --- a/src/components/resource-policy/ResourcePolicySubForms.tsx +++ b/src/components/resource-policy/ResourcePolicySubForms.tsx @@ -121,6 +121,62 @@ type LocalRule = { updated?: boolean; }; +// ─── PolicyNameSection ────────────────────────────────────────────────── +type PolicyNameSectionProps = { + form: UseFormReturn; + isEditing?: boolean; +}; + +export function PolicyNameSection({ form }: PolicyNameSectionProps) { + const t = useTranslations(); + return ( + + + + {t("resourcePolicyName")} + + + {t("resourcePolicyNameDescription")} + + + + + ( + + {t("name")} + + + + + + )} + /> + + + +
+ +
+
+ ); +} + +// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── + type PolicyUsersRolesSectionProps = { form: UseFormReturn; allRoles: { id: string; text: string }[]; @@ -128,8 +184,6 @@ type PolicyUsersRolesSectionProps = { allIdps: { id: number; text: string }[]; }; -// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── - export function PolicyUsersRolesSection({ form, allRoles, diff --git a/src/components/resource-policy/index.ts b/src/components/resource-policy/index.ts index 7b77faddb..8579a6de5 100644 --- a/src/components/resource-policy/index.ts +++ b/src/components/resource-policy/index.ts @@ -45,3 +45,21 @@ export const createPolicySchema = z.object({ }); export type PolicyFormValues = z.infer; + +export const addRuleSchema = z.object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.coerce.number().int().optional() +}); + +export type LocalRule = { + ruleId: number; + action: "ACCEPT" | "DROP" | "PASS"; + match: string; + value: string; + priority: number; + enabled: boolean; + new?: boolean; + updated?: boolean; +}; From d6a80216137ffab2546a21867aaef071cc82d7ad Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 27 Feb 2026 04:21:20 +0100 Subject: [PATCH 29/89] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20update=20resource?= =?UTF-8?q?=20policy=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 4 + server/routers/external.ts | 7 + server/routers/policy/index.ts | 1 + .../routers/policy/updateResourcePolicy.ts | 9 +- .../policies/resource/[niceId]/page.tsx | 14 +- .../resource-policy/CreatePolicyForm.tsx | 10 +- .../resource-policy/EditPolicyForm.tsx | 313 +++++++++++------- src/providers/ResourcePolicyProvider.tsx | 63 ++++ 8 files changed, 272 insertions(+), 149 deletions(-) rename server/{private => }/routers/policy/updateResourcePolicy.ts (97%) create mode 100644 src/providers/ResourcePolicyProvider.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 78b4ad0ba..3a0c77462 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -642,7 +642,11 @@ "policyErrorCreate": "Error creating policy", "policyErrorCreateDescription": "An error occurred when creating the policy", "policyErrorCreateMessageDescription": "An unexpected error occurred", + "policyErrorUpdate": "Error updating policy", + "policyErrorUpdateDescription": "An error occurred when updating the policy", + "policyErrorUpdateMessageDescription": "An unexpected error occurred", "policyCreatedSuccess": "Resource policy succesfully created", + "policyUpdatedSuccess": "Resource policy succesfully updated", "resourceErrorCreate": "Error creating resource", "resourceErrorCreateDescription": "An error occurred when creating the resource", "resourceErrorCreateMessage": "Error creating resource:", diff --git a/server/routers/external.ts b/server/routers/external.ts index 67494c643..519d2b4a9 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -638,6 +638,13 @@ authenticated.get( policy.getResourcePolicy ); +authenticated.put( + "/resource-policy/:resourcePolicyId", + verifyResourcePolicyAccess, + verifyUserHasAction(ActionsEnum.updateResourcePolicy), + policy.updateResourcePolicy +); + // authenticated.get( // "/role/:roleId", // verifyRoleAccess, diff --git a/server/routers/policy/index.ts b/server/routers/policy/index.ts index a05c292ee..8cd264925 100644 --- a/server/routers/policy/index.ts +++ b/server/routers/policy/index.ts @@ -1 +1,2 @@ export * from "./getResourcePolicy"; +export * from "./updateResourcePolicy"; diff --git a/server/private/routers/policy/updateResourcePolicy.ts b/server/routers/policy/updateResourcePolicy.ts similarity index 97% rename from server/private/routers/policy/updateResourcePolicy.ts rename to server/routers/policy/updateResourcePolicy.ts index 1f4ff5971..77443e1a2 100644 --- a/server/private/routers/policy/updateResourcePolicy.ts +++ b/server/routers/policy/updateResourcePolicy.ts @@ -4,14 +4,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { fromError } from "zod-validation-error"; -import { - db, - orgs, - resourcePolicies, - rolePolicies, - userPolicies, - type ResourcePolicy -} from "@server/db"; +import { db, orgs, resourcePolicies, type ResourcePolicy } from "@server/db"; import { and, eq } from "drizzle-orm"; import logger from "@server/logger"; import response from "@server/lib/response"; diff --git a/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx b/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx index 61833a1f1..9c18c9b96 100644 --- a/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx +++ b/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx @@ -3,7 +3,7 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { Button } from "@app/components/ui/button"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; -import type { ResourcePolicy } from "@server/db"; +import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider"; import type { GetResourcePolicyResponse } from "@server/routers/policy"; import type { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; @@ -18,7 +18,7 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) { const params = await props.params; const t = await getTranslations(); - let policy: ResourcePolicy | null = null; + let policyResponse: GetResourcePolicyResponse | null = null; try { const res = await internal.get< AxiosResponse @@ -26,12 +26,12 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) { `/org/${params.orgId}/resource-policy/${params.niceId}`, await authCookieHeader() ); - policy = res.data.data.policy; + policyResponse = res.data.data; } catch { redirect(`/${params.orgId}/settings/policies/resource`); } - if (!policy) { + if (!policyResponse) { redirect(`/${params.orgId}/settings/policies/resource`); } @@ -40,7 +40,7 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) {
@@ -52,7 +52,9 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) {
- + + + ); } diff --git a/src/components/resource-policy/CreatePolicyForm.tsx b/src/components/resource-policy/CreatePolicyForm.tsx index 10982c0cc..d805e8772 100644 --- a/src/components/resource-policy/CreatePolicyForm.tsx +++ b/src/components/resource-policy/CreatePolicyForm.tsx @@ -204,14 +204,10 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { }); if (res && res.status === 201) { - const id = res.data.data.resourcePolicyId; const niceId = res.data.data.niceId; - - router.push(`/${org.org.orgId}/settings/policies/resources/`); - // should redirect to the details page - // router.push( - // `/${org.org.orgId}/settings/policies/resources/${niceId}` - // ); + router.push( + `/${org.org.orgId}/settings/policies/resource/${niceId}` + ); toast({ title: t("success"), description: t("policyCreatedSuccess") diff --git a/src/components/resource-policy/EditPolicyForm.tsx b/src/components/resource-policy/EditPolicyForm.tsx index 4331fb286..5477e8704 100644 --- a/src/components/resource-policy/EditPolicyForm.tsx +++ b/src/components/resource-policy/EditPolicyForm.tsx @@ -121,23 +121,21 @@ import { import { useCallback, useMemo, useState, useActionState } from "react"; import { UseFormReturn, useForm, useWatch } from "react-hook-form"; +import router from "next/navigation"; +import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; // ─── EditPolicyForm ───────────────────────────────────────────────────────── export type EditPolicyFormProps = { - policy: ResourcePolicy; hidePolicyNameForm?: boolean; }; -export function EditPolicyForm({ - hidePolicyNameForm, - policy -}: EditPolicyFormProps) { +export function EditPolicyForm({ hidePolicyNameForm }: EditPolicyFormProps) { const { org } = useOrgContext(); const t = useTranslations(); const { env } = useEnvContext(); const api = createApiClient({ env }); - const [, formAction, isSubmitting] = useActionState(onSubmit, null); + // const [, formAction, isSubmitting] = useActionState(onSubmit, null); const { isPaidUser } = usePaidStatus(); const router = useRouter(); @@ -145,7 +143,7 @@ export function EditPolicyForm({ const isMaxmindAvailable = !!( env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0 ); - const isMaxmindAsnAvailable = !!( + const isMaxmindASNAvailable = !!( env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0 ); @@ -162,75 +160,76 @@ export function EditPolicyForm({ }) ); - const form = useForm({ - resolver: zodResolver(createPolicySchema) as any, - defaultValues: { - name: policy.name, - sso: true, - skipToIdpId: null, - emailWhitelistEnabled: false, - roles: [], - users: [], - emails: [], - applyRules: false, - rules: [], - password: null, - headerAuth: null, - pincode: null - } - }); + // const form = useForm({ + // resolver: zodResolver(createPolicySchema) as any, + // defaultValues: { + // name: "", + // sso: true, + // skipToIdpId: null, + // emailWhitelistEnabled: false, + // roles: [], + // users: [], + // emails: [], + // applyRules: false, + // rules: [], + // password: null, + // headerAuth: null, + // pincode: null + // } + // }); - async function onSubmit() { - const isValid = await form.trigger(); + // async function onSubmit() { + // return; + // // const isValid = await form.trigger(); - if (!isValid) return; + // // if (!isValid) return; - const payload = form.getValues(); + // // const payload = form.getValues(); - try { - const res = await api - .post>( - `/org/${org.org.orgId}/resource-policy/`, - { - name: payload.name, - sso: payload.sso, - roleIds: payload.roles.map((r) => r.id), - userIds: payload.users.map((u) => u.id) - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("policyErrorCreate"), - description: formatAxiosError( - e, - t("policyErrorCreateDescription") - ) - }); - }); + // // try { + // // const res = await api + // // .post>( + // // `/org/${org.org.orgId}/resource-policy/`, + // // { + // // name: payload.name, + // // sso: payload.sso, + // // roleIds: payload.roles.map((r) => r.id), + // // userIds: payload.users.map((u) => u.id) + // // } + // // ) + // // .catch((e) => { + // // toast({ + // // variant: "destructive", + // // title: t("policyErrorCreate"), + // // description: formatAxiosError( + // // e, + // // t("policyErrorCreateDescription") + // // ) + // // }); + // // }); - if (res && res.status === 201) { - const id = res.data.data.resourcePolicyId; - const niceId = res.data.data.niceId; + // // if (res && res.status === 201) { + // // const id = res.data.data.resourcePolicyId; + // // const niceId = res.data.data.niceId; - router.push(`/${org.org.orgId}/settings/policies/resources/`); - // should redirect to the details page - // router.push( - // `/${org.org.orgId}/settings/policies/resources/${niceId}` - // ); - toast({ - title: t("success"), - description: t("policyCreatedSuccess") - }); - } - } catch (e) { - toast({ - variant: "destructive", - title: t("policyErrorCreate"), - description: t("policyErrorCreateMessageDescription") - }); - } - } + // // router.push(`/${org.org.orgId}/settings/policies/resources/`); + // // // should redirect to the details page + // // // router.push( + // // // `/${org.org.orgId}/settings/policies/resources/${niceId}` + // // // ); + // // toast({ + // // title: t("success"), + // // description: t("policyCreatedSuccess") + // // }); + // // } + // // } catch (e) { + // // toast({ + // // variant: "destructive", + // // title: t("policyErrorCreate"), + // // description: t("policyErrorCreateMessageDescription") + // // }); + // // } + // } const allRoles = useMemo( () => @@ -271,12 +270,12 @@ export function EditPolicyForm({ } return ( -
- - - {/* Name */} - {!hidePolicyNameForm && } - + // + + {/* Name */} + {!hidePolicyNameForm && } + {/* - - - + isMaxmindAsnAvailable={isMaxmindASNAvailable} + /> */} +
+ // + // ); } // ─── PolicyNameSection ────────────────────────────────────────────────── -type PolicyNameSectionProps = { - form: UseFormReturn; - isEditing?: boolean; -}; -export function PolicyNameSection({ form }: PolicyNameSectionProps) { +export function PolicyNameSection() { const t = useTranslations(); - return ( - - - - {t("resourcePolicyName")} - - - {t("resourcePolicyNameDescription")} - - - - - ( - - {t("name")} - - - - - - )} - /> - - + const api = createApiClient(useEnvContext()); -
- -
-
+ const { policy } = useResourcePolicyContext(); + const { org } = useOrgContext(); + const form = useForm({ + resolver: zodResolver( + z.object({ + name: z.string() + }) + ), + defaultValues: { + name: policy.name + } + }); + + const [, formAction, isSubmitting] = useActionState(onSubmit, null); + + async function onSubmit() { + const isValid = await form.trigger(); + + if (!isValid) return; + + const payload = form.getValues(); + + try { + const res = await api + .put>( + `/resource-policy/${policy.resourcePolicyId}`, + { + name: payload.name + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }); + + if (res && res.status === 200) { + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: t("policyErrorUpdateMessageDescription") + }); + } + } + + return ( +
+ + + + + {t("resourcePolicyName")} + + + {t("resourcePolicyNameDescription")} + + + + + ( + + {t("name")} + + + + + + )} + /> + + + +
+ +
+
+
+ ); } diff --git a/src/providers/ResourcePolicyProvider.tsx b/src/providers/ResourcePolicyProvider.tsx new file mode 100644 index 000000000..c6300304b --- /dev/null +++ b/src/providers/ResourcePolicyProvider.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { createContext, useContext, useState } from "react"; +import { useTranslations } from "next-intl"; +import type { GetResourcePolicyResponse } from "@server/routers/policy"; + +interface ResourcePolicyProviderProps { + children: React.ReactNode; + policy: GetResourcePolicyResponse; +} + +export function ResourcePolicyProvider({ + children, + policy: serverPolicy +}: ResourcePolicyProviderProps) { + const [policy, setPolicy] = + useState(serverPolicy); + + const t = useTranslations(); + + const updatePolicy = ( + updatedPolicy: Partial + ) => { + if (!policy) { + throw new Error(t("resourceErrorNoUpdate")); + } + + setPolicy((prev) => { + if (!prev) { + return prev; + } + + return { + ...prev, + ...updatedPolicy + }; + }); + }; + + return ( + + {children} + + ); +} + +export type ResourcePolicyContextType = GetResourcePolicyResponse & { + updatePolicy: (updatedPolicy: Partial) => void; +}; + +export const ResourcePolicyContext = createContext< + ResourcePolicyContextType | undefined +>(undefined); + +export function useResourcePolicyContext() { + const context = useContext(ResourcePolicyContext); + if (context === undefined) { + throw new Error( + "useResourcePolicyContext must be used within a ResourcePolicyProvider" + ); + } + return context; +} From 2ef5d90e13d46e29e4b49db8c13b01454a9d2bb9 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 27 Feb 2026 04:24:33 +0100 Subject: [PATCH 30/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20update=20policy=20in?= =?UTF-8?q?=20integration=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/integration.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server/routers/integration.ts b/server/routers/integration.ts index e52e710ee..3d4f29fec 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -410,6 +410,13 @@ authenticated.post( resource.updateResource ); +authenticated.put( + "/resource-policy/:resourcePolicyId", + verifyApiKeyResourcePolicyAccess, + verifyApiKeyHasAction(ActionsEnum.updateResourcePolicy), + policy.updateResourcePolicy +); + authenticated.delete( "/resource/:resourceId", verifyApiKeyResourceAccess, From 7b02d4104d0fedd679a207e287b799cb3a329562 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 28 Feb 2026 00:47:27 +0100 Subject: [PATCH 31/89] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../policy/setResourcePolicyAccessControl.ts | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 server/routers/policy/setResourcePolicyAccessControl.ts diff --git a/server/routers/policy/setResourcePolicyAccessControl.ts b/server/routers/policy/setResourcePolicyAccessControl.ts new file mode 100644 index 000000000..72541642d --- /dev/null +++ b/server/routers/policy/setResourcePolicyAccessControl.ts @@ -0,0 +1,98 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { userResources } from "@server/db"; +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 { eq } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const setUserResourcesBodySchema = z.strictObject({ + userIds: z.array(z.string()) +}); + +const setResourcePolicyAccessControlParamsSchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/resource-policy/{resourceId}/access-control", + description: + "Set access control users for a resource policy, including SSO, users, authentication.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: setResourcePolicyAccessControlParamsSchema, + body: { + content: { + "application/json": { + schema: setUserResourcesBodySchema + } + } + } + }, + responses: {} +}); + +export async function setResourceUsers( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setUserResourcesBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userIds } = parsedBody.data; + + const parsedParams = setUserResourcesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + await db.transaction(async (trx) => { + await trx + .delete(userResources) + .where(eq(userResources.resourceId, resourceId)); + + const newUserResources = await Promise.all( + userIds.map((userId) => + trx + .insert(userResources) + .values({ userId, resourceId }) + .returning() + ) + ); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Users set for resource successfully", + status: HttpCode.CREATED + }); + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} From 18964ba2a32498a3742835506502a0ebf325ca97 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 28 Feb 2026 14:22:41 +0100 Subject: [PATCH 32/89] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../policy/setResourcePolicyAccessControl.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/server/routers/policy/setResourcePolicyAccessControl.ts b/server/routers/policy/setResourcePolicyAccessControl.ts index 72541642d..14417df29 100644 --- a/server/routers/policy/setResourcePolicyAccessControl.ts +++ b/server/routers/policy/setResourcePolicyAccessControl.ts @@ -10,8 +10,11 @@ import { fromError } from "zod-validation-error"; import { eq } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -const setUserResourcesBodySchema = z.strictObject({ - userIds: z.array(z.string()) +const setResourcePolicyAcccessControlBodySchema = z.strictObject({ + sso: z.boolean(), + userIds: z.array(z.string()), + roleIds: z.array(z.int().positive()), + skipToIdpId: z.string().optional() }); const setResourcePolicyAccessControlParamsSchema = z.strictObject({ @@ -22,14 +25,14 @@ registry.registerPath({ method: "post", path: "/resource-policy/{resourceId}/access-control", description: - "Set access control users for a resource policy, including SSO, users, authentication.", + "Set access control users for a resource policy, including SSO, users, roles, Identity provider.", tags: [OpenAPITags.Resource, OpenAPITags.User], request: { params: setResourcePolicyAccessControlParamsSchema, body: { content: { "application/json": { - schema: setUserResourcesBodySchema + schema: setResourcePolicyAcccessControlBodySchema } } } @@ -43,7 +46,9 @@ export async function setResourceUsers( next: NextFunction ): Promise { try { - const parsedBody = setUserResourcesBodySchema.safeParse(req.body); + const parsedBody = setResourcePolicyAcccessControlBodySchema.safeParse( + req.body + ); if (!parsedBody.success) { return next( createHttpError( From e7ab9b3f37dc0217611f7bdabda4b42e07e97b73 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 2 Mar 2026 18:32:08 +0100 Subject: [PATCH 33/89] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../policy/setResourcePolicyAccessControl.ts | 20 +++++++++---------- .../resource-policy/EditPolicyForm.tsx | 5 +++-- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/server/routers/policy/setResourcePolicyAccessControl.ts b/server/routers/policy/setResourcePolicyAccessControl.ts index 14417df29..430c9e59f 100644 --- a/server/routers/policy/setResourcePolicyAccessControl.ts +++ b/server/routers/policy/setResourcePolicyAccessControl.ts @@ -60,7 +60,8 @@ export async function setResourceUsers( const { userIds } = parsedBody.data; - const parsedParams = setUserResourcesParamsSchema.safeParse(req.params); + const parsedParams = + setResourcePolicyAccessControlParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( @@ -70,7 +71,7 @@ export async function setResourceUsers( ); } - const { resourceId } = parsedParams.data; + const { resourcePolicyId } = parsedParams.data; await db.transaction(async (trx) => { await trx @@ -85,14 +86,13 @@ export async function setResourceUsers( .returning() ) ); - - return response(res, { - data: {}, - success: true, - error: false, - message: "Users set for resource successfully", - status: HttpCode.CREATED - }); + }); + return response(res, { + data: {}, + success: true, + error: false, + message: "Users set for resource successfully", + status: HttpCode.CREATED }); } catch (error) { logger.error(error); diff --git a/src/components/resource-policy/EditPolicyForm.tsx b/src/components/resource-policy/EditPolicyForm.tsx index 5477e8704..16f85cdca 100644 --- a/src/components/resource-policy/EditPolicyForm.tsx +++ b/src/components/resource-policy/EditPolicyForm.tsx @@ -276,11 +276,12 @@ export function EditPolicyForm({ hidePolicyNameForm }: EditPolicyFormProps) { {/* Name */} {!hidePolicyNameForm && } {/* + /> */} + {/* Date: Mon, 2 Mar 2026 19:26:51 +0100 Subject: [PATCH 34/89] =?UTF-8?q?=E2=9C=A8=20update=20policy=20access=20co?= =?UTF-8?q?ntrol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routers/policy/createResourcePolicy.ts | 6 +- .../policy/setResourcePolicyAccessControl.ts | 163 ++++++++++++++++-- 2 files changed, 150 insertions(+), 19 deletions(-) diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts index 6cf710810..dc6780616 100644 --- a/server/private/routers/policy/createResourcePolicy.ts +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -27,7 +27,7 @@ const createResourcePolicyParamsSchema = z.strictObject({ const createResourcePolicyBodySchema = z.strictObject({ name: z.string().min(1).max(255), sso: z.boolean(), - skipToIdpId: z.string().optional(), + skipToIdpId: z.int().positive().optional(), roleIds: z .array(z.string().transform(Number).pipe(z.int().positive())) .optional() @@ -150,7 +150,9 @@ export async function createResourcePolicy( .select() .from(users) .innerJoin(userOrgs, eq(userOrgs.userId, users.userId)) - .where(and(inArray(users.userId, userIds))); + .where( + and(eq(userOrgs.orgId, orgId), inArray(users.userId, userIds)) + ); const niceId = await getUniqueResourcePolicyName(orgId); diff --git a/server/routers/policy/setResourcePolicyAccessControl.ts b/server/routers/policy/setResourcePolicyAccessControl.ts index 430c9e59f..98f43f5fa 100644 --- a/server/routers/policy/setResourcePolicyAccessControl.ts +++ b/server/routers/policy/setResourcePolicyAccessControl.ts @@ -1,20 +1,29 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; -import { userResources } from "@server/db"; +import { + db, + idp, + idpOrg, + resourcePolicies, + rolePolicies, + roles, + userOrgs, + users +} from "@server/db"; +import { userPolicies } from "@server/db"; 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 { eq } from "drizzle-orm"; +import { and, eq, inArray, ne, type InferInsertModel } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; const setResourcePolicyAcccessControlBodySchema = z.strictObject({ sso: z.boolean(), userIds: z.array(z.string()), roleIds: z.array(z.int().positive()), - skipToIdpId: z.string().optional() + skipToIdpId: z.int().positive().optional() }); const setResourcePolicyAccessControlParamsSchema = z.strictObject({ @@ -58,7 +67,7 @@ export async function setResourceUsers( ); } - const { userIds } = parsedBody.data; + const { userIds, roleIds, sso, skipToIdpId: idpId } = parsedBody.data; const parsedParams = setResourcePolicyAccessControlParamsSchema.safeParse(req.params); @@ -73,26 +82,146 @@ export async function setResourceUsers( const { resourcePolicyId } = parsedParams.data; - await db.transaction(async (trx) => { - await trx - .delete(userResources) - .where(eq(userResources.resourceId, resourceId)); + const [policy] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .limit(1); - const newUserResources = await Promise.all( - userIds.map((userId) => - trx - .insert(userResources) - .values({ userId, resourceId }) - .returning() + if (!policy) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Resource policy not found" ) ); + } + + // Check if Identity provider in `skipToIdpId` exists + if (idpId) { + const [provider] = await db + .select() + .from(idp) + .innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId)) + .where( + and(eq(idp.idpId, idpId), eq(idpOrg.orgId, policy.orgId)) + ) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Identity provider not found in this organization" + ) + ); + } + } + + // Check if any of the roleIds are admin roles + const rolesToCheck = await db + .select() + .from(roles) + .where( + and( + inArray(roles.roleId, roleIds), + eq(roles.orgId, policy.orgId) + ) + ); + + const hasAdminRole = rolesToCheck.some((role) => role.isAdmin); + + if (hasAdminRole) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Admin role cannot be assigned to resources" + ) + ); + } + + // Get all admin role IDs for this org to exclude from deletion + const adminRoles = await db + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, policy.orgId))); + const adminRoleIds = adminRoles.map((role) => role.roleId); + + const existingUsers = await db + .select() + .from(users) + .innerJoin(userOrgs, eq(userOrgs.userId, users.userId)) + .where( + and( + eq(userOrgs.orgId, policy.orgId), + inArray(users.userId, userIds) + ) + ); + + const existingRoles = await db + .select() + .from(roles) + .where( + and( + eq(roles.orgId, policy.orgId), + inArray(roles.roleId, roleIds) + ) + ); + + await db.transaction(async (trx) => { + // Update SSO status + await trx + .update(resourcePolicies) + .set({ + sso, + idpId + }) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)); + + // Update roles + if (adminRoleIds.length > 0) { + await trx.delete(rolePolicies).where( + and( + eq(rolePolicies.resourcePolicyId, resourcePolicyId), + ne(rolePolicies.roleId, adminRoleIds[0]) // delete all but the admin role + ) + ); + } else { + await trx + .delete(rolePolicies) + .where(eq(rolePolicies.resourcePolicyId, resourcePolicyId)); + } + + const rolesToAdd = existingRoles.map(({ roleId }) => ({ + roleId, + resourcePolicyId + })); + + if (rolesToAdd.length > 0) { + await trx.insert(rolePolicies).values(rolesToAdd); + } + + // Update users + await trx + .delete(userPolicies) + .where(eq(userPolicies.resourcePolicyId, resourcePolicyId)); + + const usersToAdd = existingUsers.map(({ user }) => ({ + userId: user.userId, + resourcePolicyId: resourcePolicyId + })); + + if (usersToAdd.length > 0) { + await trx.insert(userPolicies).values(usersToAdd); + } }); + return response(res, { data: {}, success: true, error: false, - message: "Users set for resource successfully", - status: HttpCode.CREATED + message: "Resource policy succesfully updated", + status: HttpCode.OK }); } catch (error) { logger.error(error); From 033cc62ce7dcb11c01f0e39e99510bed87afd02f Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 2 Mar 2026 19:37:23 +0100 Subject: [PATCH 35/89] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/external.ts | 8 +++++ server/routers/integration.ts | 15 ++++++++- server/routers/policy/index.ts | 1 + .../policy/setResourcePolicyAccessControl.ts | 2 +- .../resource-policy/EditPolicyForm.tsx | 31 ++++++++++++++----- 5 files changed, 47 insertions(+), 10 deletions(-) diff --git a/server/routers/external.ts b/server/routers/external.ts index 715988cf3..bb4ee7d31 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -700,6 +700,14 @@ authenticated.get( resource.listResourcePolicyRoles ); +authenticated.put( + "/resource-policy/:resourcePolicyId/access-control", + verifyResourcePolicyAccess, + verifyUserHasAction(ActionsEnum.setResourcePolicyUsers), + verifyUserHasAction(ActionsEnum.setResourcePolicyRoles), + policy.setResourcePolicyAccessControl +); + authenticated.get( "/resource-policy/:resourcePolicyId/users", verifyResourcePolicyAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 41557ba30..a68bcb555 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -30,7 +30,8 @@ import { verifyApiKeySetResourceClients, verifyLimits, verifyApiKeyDomainAccess, - verifyApiKeyResourcePolicyAccess + verifyApiKeyResourcePolicyAccess, + verifyUserHasAction } from "@server/middlewares"; import HttpCode from "@server/types/HttpCode"; import { Router } from "express"; @@ -619,6 +620,18 @@ authenticated.post( resource.setResourceUsers ); +authenticated.put( + "/resource-policy/:resourcePolicyId/access-control", + verifyApiKeyResourcePolicyAccess, + verifyApiKeyRoleAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.setResourcePolicyUsers), + verifyUserHasAction(ActionsEnum.setResourcePolicyRoles), + logActionAudit(ActionsEnum.setResourcePolicyUsers), + logActionAudit(ActionsEnum.setResourcePolicyRoles), + policy.setResourcePolicyAccessControl +); + authenticated.post( "/resource/:resourceId/roles/add", verifyApiKeyResourceAccess, diff --git a/server/routers/policy/index.ts b/server/routers/policy/index.ts index 8cd264925..9ad10eb45 100644 --- a/server/routers/policy/index.ts +++ b/server/routers/policy/index.ts @@ -1,2 +1,3 @@ export * from "./getResourcePolicy"; export * from "./updateResourcePolicy"; +export * from "./setResourcePolicyAccessControl"; diff --git a/server/routers/policy/setResourcePolicyAccessControl.ts b/server/routers/policy/setResourcePolicyAccessControl.ts index 98f43f5fa..926478db8 100644 --- a/server/routers/policy/setResourcePolicyAccessControl.ts +++ b/server/routers/policy/setResourcePolicyAccessControl.ts @@ -49,7 +49,7 @@ registry.registerPath({ responses: {} }); -export async function setResourceUsers( +export async function setResourcePolicyAccessControl( req: Request, res: Response, next: NextFunction diff --git a/src/components/resource-policy/EditPolicyForm.tsx b/src/components/resource-policy/EditPolicyForm.tsx index 16f85cdca..88c6ceafa 100644 --- a/src/components/resource-policy/EditPolicyForm.tsx +++ b/src/components/resource-policy/EditPolicyForm.tsx @@ -275,12 +275,11 @@ export function EditPolicyForm({ hidePolicyNameForm }: EditPolicyFormProps) { {/* Name */} {!hidePolicyNameForm && } - {/* */} + {/* ; allRoles: { id: string; text: string }[]; allUsers: { id: string; text: string }[]; allIdps: { id: number; text: string }[]; }; export function PolicyUsersRolesSection({ - form, allRoles, allUsers, allIdps }: PolicyUsersRolesSectionProps) { const t = useTranslations(); + + const { policy } = useResourcePolicyContext(); + + const form = useForm({ + resolver: zodResolver( + createPolicySchema.pick({ + sso: true, + skipToIdpId: true, + users: true, + roles: true + }) + ), + defaultValues: { + sso: policy.sso, + skipToIdpId: policy.idpId + } + }); + const ssoEnabled = useWatch({ control: form.control, name: "sso" }); const selectedIdpId = useWatch({ control: form.control, From 5c280b024ea15888ced726f18e66ba5c6463ac23 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 3 Mar 2026 01:33:37 +0100 Subject: [PATCH 36/89] =?UTF-8?q?=E2=9C=A8=20update=20policy=20access=20co?= =?UTF-8?q?ntrol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routers/policy/createResourcePolicy.ts | 29 +- server/routers/policy/getResourcePolicy.ts | 64 ++- .../policy/setResourcePolicyAccessControl.ts | 4 +- .../policies/resource/[niceId]/page.tsx | 2 +- .../resource-policy/EditPolicyForm.tsx | 421 +++++++++++------- src/providers/ResourcePolicyProvider.tsx | 5 +- 6 files changed, 335 insertions(+), 190 deletions(-) diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts index dc6780616..29bccd48b 100644 --- a/server/private/routers/policy/createResourcePolicy.ts +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -6,6 +6,8 @@ import createHttpError from "http-errors"; import { fromError } from "zod-validation-error"; import { db, + idp, + idpOrg, orgs, resourcePolicies, rolePolicies, @@ -107,15 +109,23 @@ export async function createResourcePolicy( const { name, sso, userIds, roleIds, skipToIdpId } = parsedBody.data; - const isAuthEnabeld = sso; // other conditions will follow + // Check if Identity provider in `skipToIdpId` exists + if (skipToIdpId) { + const [provider] = await db + .select() + .from(idp) + .innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId)) + .where(and(eq(idp.idpId, skipToIdpId), eq(idpOrg.orgId, orgId))) + .limit(1); - if (!isAuthEnabeld) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "At least one authentication policy must be set: platform SSO, an authentication method, one-time password, or a rule." - ) - ); + if (!provider) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Identity provider not found in this organization" + ) + ); + } } const adminRole = await db @@ -163,7 +173,8 @@ export async function createResourcePolicy( niceId, orgId, name, - sso + sso, + idpId: skipToIdpId }) .returning(); diff --git a/server/routers/policy/getResourcePolicy.ts b/server/routers/policy/getResourcePolicy.ts index 0d33a222e..3b42ffc2e 100644 --- a/server/routers/policy/getResourcePolicy.ts +++ b/server/routers/policy/getResourcePolicy.ts @@ -1,9 +1,17 @@ -import { db, resourcePolicies, rolePolicies, userPolicies } from "@server/db"; +import { + db, + idp, + resourcePolicies, + rolePolicies, + roles, + userPolicies, + users +} from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; -import { and, eq, type SQL } from "drizzle-orm"; +import { and, eq, isNull, not, or, type SQL } from "drizzle-orm"; import type { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import z from "zod"; @@ -34,26 +42,48 @@ async function query(params: z.infer) { } const [res] = await db - .select({ - policy: resourcePolicies, - userPolicies, - rolePolicies - }) + .select() .from(resourcePolicies) - .leftJoin( - userPolicies, - eq(userPolicies.resourcePolicyId, resourcePolicies.resourcePolicyId) - ) - .leftJoin( - rolePolicies, - eq(rolePolicies.resourcePolicyId, resourcePolicies.resourcePolicyId) - ) .where(and(...conditions)) .limit(1); - return res; + + if (!res) return null; + + const policyUsers = await db + .select({ + userId: userPolicies.userId, + email: users.email, + name: users.name, + username: users.username, + type: users.type, + idpName: idp.name + }) + .from(userPolicies) + .innerJoin(users, eq(userPolicies.userId, users.userId)) + .leftJoin(idp, eq(idp.idpId, users.idpId)) + .where(eq(userPolicies.resourcePolicyId, res.resourcePolicyId)); + + const policyRoles = await db + .select({ + roleId: rolePolicies.roleId, + name: roles.name + }) + .from(rolePolicies) + .innerJoin( + roles, + and( + eq(rolePolicies.roleId, roles.roleId), + or(isNull(roles.isAdmin), not(roles.isAdmin)) + ) + ) + .where(eq(rolePolicies.resourcePolicyId, res.resourcePolicyId)); + + return { ...res, roles: policyRoles, users: policyUsers }; } -export type GetResourcePolicyResponse = Awaited>; +export type GetResourcePolicyResponse = NonNullable< + Awaited> +>; registry.registerPath({ method: "get", diff --git a/server/routers/policy/setResourcePolicyAccessControl.ts b/server/routers/policy/setResourcePolicyAccessControl.ts index 926478db8..7bbbe8a38 100644 --- a/server/routers/policy/setResourcePolicyAccessControl.ts +++ b/server/routers/policy/setResourcePolicyAccessControl.ts @@ -16,14 +16,14 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { and, eq, inArray, ne, type InferInsertModel } from "drizzle-orm"; +import { and, eq, inArray, ne } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; const setResourcePolicyAcccessControlBodySchema = z.strictObject({ sso: z.boolean(), userIds: z.array(z.string()), roleIds: z.array(z.int().positive()), - skipToIdpId: z.int().positive().optional() + skipToIdpId: z.int().positive().optional().nullish() }); const setResourcePolicyAccessControlParamsSchema = z.strictObject({ diff --git a/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx b/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx index 9c18c9b96..5519506b9 100644 --- a/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx +++ b/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx @@ -40,7 +40,7 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) {
diff --git a/src/components/resource-policy/EditPolicyForm.tsx b/src/components/resource-policy/EditPolicyForm.tsx index 88c6ceafa..20c7a76e6 100644 --- a/src/components/resource-policy/EditPolicyForm.tsx +++ b/src/components/resource-policy/EditPolicyForm.tsx @@ -123,6 +123,7 @@ import { useCallback, useMemo, useState, useActionState } from "react"; import { UseFormReturn, useForm, useWatch } from "react-hook-form"; import router from "next/navigation"; import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; +import { t } from "@faker-js/faker/dist/airline-CWrCIUHH"; // ─── EditPolicyForm ───────────────────────────────────────────────────────── @@ -426,6 +427,10 @@ export function PolicyUsersRolesSection({ const { policy } = useResourcePolicyContext(); + const api = createApiClient(useEnvContext()); + + console.log({ policy }); + const form = useForm({ resolver: zodResolver( createPolicySchema.pick({ @@ -437,7 +442,15 @@ export function PolicyUsersRolesSection({ ), defaultValues: { sso: policy.sso, - skipToIdpId: policy.idpId + skipToIdpId: policy.idpId, + roles: policy.roles.map((role) => ({ + id: role.roleId.toString(), + text: role.name + })), + users: policy.users.map((user) => ({ + id: user.userId, + text: `${getUserDisplayName({ email: user.email, username: user.username })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` + })) } }); @@ -453,167 +466,257 @@ export function PolicyUsersRolesSection({ number | null >(null); + const [, formAction, isSubmitting] = useActionState(onSubmit, null); + + async function onSubmit() { + const isValid = await form.trigger(); + + if (!isValid) return; + + const payload = form.getValues(); + + try { + const res = await api + .put>( + `/resource-policy/${policy.resourcePolicyId}/access-control`, + { + sso: payload.sso, + userIds: payload.users.map((user) => user.id), + roleIds: payload.roles.map((role) => Number(role.id)), + skipToIdpId: payload.skipToIdpId + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }); + + if (res && res.status === 200) { + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: t("policyErrorUpdateMessageDescription") + }); + } + } + return ( - - - - {t("resourceUsersRoles")} - - - {t("resourcePolicyUsersRolesDescription")} - - - - - { - console.log(`form.setValue("sso", ${val})`); - form.setValue("sso", val); - }} - /> - - {ssoEnabled && ( - <> - ( - - {t("roles")} - - { - form.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={true} - autocompleteOptions={allRoles} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - {t("resourceRoleDescription")} - - - )} - /> - ( - - {t("users")} - - { - form.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={true} - autocompleteOptions={allUsers} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - - )} - - {ssoEnabled && allIdps.length > 0 && ( -
- - -

- {t("defaultIdentityProviderDescription")} -

-
- )} -
-
-
+ ( + + + {t("users")} + + + { + form.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allUsers + } + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + + )} + + {ssoEnabled && allIdps.length > 0 && ( +
+ + +

+ {t( + "defaultIdentityProviderDescription" + )} +

+
+ )} + + + +
+ +
+ + + ); } diff --git a/src/providers/ResourcePolicyProvider.tsx b/src/providers/ResourcePolicyProvider.tsx index c6300304b..e80704dc4 100644 --- a/src/providers/ResourcePolicyProvider.tsx +++ b/src/providers/ResourcePolicyProvider.tsx @@ -38,13 +38,14 @@ export function ResourcePolicyProvider({ }; return ( - + {children} ); } -export type ResourcePolicyContextType = GetResourcePolicyResponse & { +export type ResourcePolicyContextType = { + policy: GetResourcePolicyResponse; updatePolicy: (updatedPolicy: Partial) => void; }; From 8a54fb7f23ff462deb1695e4c85ff9fa0fe74e90 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 3 Mar 2026 02:11:05 +0100 Subject: [PATCH 37/89] =?UTF-8?q?=F0=9F=9A=A7=20auth=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resource-policy/EditPolicyForm.tsx | 282 +++++++++++------- 1 file changed, 166 insertions(+), 116 deletions(-) diff --git a/src/components/resource-policy/EditPolicyForm.tsx b/src/components/resource-policy/EditPolicyForm.tsx index 20c7a76e6..d138ff1a8 100644 --- a/src/components/resource-policy/EditPolicyForm.tsx +++ b/src/components/resource-policy/EditPolicyForm.tsx @@ -281,8 +281,8 @@ export function EditPolicyForm({ hidePolicyNameForm }: EditPolicyFormProps) { allUsers={allUsers} allIdps={allIdps} /> + {/* - ; -}; +export function PolicyAuthMethodsSection() { + const { policy } = useResourcePolicyContext(); + + const api = createApiClient(useEnvContext()); + + const form = useForm({ + resolver: zodResolver( + createPolicySchema.pick({ + password: true, + pincode: true, + headerAuth: true + }) + ), + defaultValues: {} + }); -export function PolicyAuthMethodsSection({ - form -}: PolicyAuthMethodsSectionProps) { const t = useTranslations(); - const [isOpen, setIsOpen] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); @@ -768,7 +775,18 @@ export function PolicyAuthMethodsSection({ defaultValues: { user: "", password: "", extendedCompatibility: true } }); - if (!isOpen) { + const [, formAction, isSubmitting] = useActionState(onSubmit, null); + + async function onSubmit() { + const isValid = await form.trigger(); + + if (!isValid) return; + + const payload = form.getValues(); + console.log({ payload }); + } + + if (!isExpanded) { return ( @@ -783,7 +801,7 @@ export function PolicyAuthMethodsSection({ -
+
+ {}}> + + + + {t("resourceAuthMethods")} + + + {t("resourcePolicyAuthMethodsDescription")} + + + + + {/* Password row */} +
+
+ + + {t("resourcePasswordProtection", { + status: password + ? t("enabled") + : t("disabled") + })} + +
+ +
- {/* Pincode row */} -
-
- - - {t("resourcePincodeProtection", { - status: pincode - ? t("enabled") - : t("disabled") - })} - -
- -
+ {/* Pincode row */} +
+
+ + + {t("resourcePincodeProtection", { + status: pincode + ? t("enabled") + : t("disabled") + })} + +
+ +
- {/* Header auth row */} -
-
- - - {headerAuth - ? t( - "resourceHeaderAuthProtectionEnabled" - ) - : t( - "resourceHeaderAuthProtectionDisabled" - )} - -
+ {/* Header auth row */} +
+
+ + + {headerAuth + ? t( + "resourceHeaderAuthProtectionEnabled" + ) + : t( + "resourceHeaderAuthProtectionDisabled" + )} + +
+ +
+ + + +
- - - + + + ); } @@ -1170,13 +1220,13 @@ export function PolicyOtpEmailSection({ emailEnabled }: PolicyOtpEmailSectionProps) { const t = useTranslations(); - const [isOpen, setIsOpen] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); const [whitelistEnabled, setWhitelistEnabled] = useState(false); const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< number | null >(null); - if (!isOpen) { + if (!isExpanded) { return ( @@ -1191,7 +1241,7 @@ export function PolicyOtpEmailSection({ + + + ); + } + + return ( + <> + {/* Password Credenza */} + { + setIsSetPasswordOpen(val); + if (!val) passwordForm.reset(); + }} + > + + + + {t("resourcePasswordSetupTitle")} + + + {t("resourcePasswordSetupTitleDescription")} + + + +
+ { + form.setValue("password", data); + setIsSetPasswordOpen(false); + passwordForm.reset(); + })} + className="space-y-4" + id="set-password-form" + > + ( + + + {t("password")} + + + + + + + )} + /> + + +
+ + + + + + +
+
+ + {/* Pincode Credenza */} + { + setIsSetPincodeOpen(val); + if (!val) pincodeForm.reset(); + }} + > + + + + {t("resourcePincodeSetupTitle")} + + + {t("resourcePincodeSetupTitleDescription")} + + + +
+ { + form.setValue("pincode", data); + setIsSetPincodeOpen(false); + pincodeForm.reset(); + })} + className="space-y-4" + id="set-pincode-form" + > + ( + + + {t("resourcePincode")} + + +
+ + + + + + + + + + +
+
+ +
+ )} + /> + + +
+ + + + + + +
+
+ + {/* Header Auth Credenza */} + { + setIsSetHeaderAuthOpen(val); + if (!val) headerAuthForm.reset(); + }} + > + + + + {t("resourceHeaderAuthSetupTitle")} + + + {t("resourceHeaderAuthSetupTitleDescription")} + + + +
+ { + form.setValue("headerAuth", data); + setIsSetHeaderAuthOpen(false); + headerAuthForm.reset(); + } + )} + className="space-y-4" + id="set-header-auth-form" + > + ( + + {t("user")} + + + + + + )} + /> + ( + + + {t("password")} + + + + + + + )} + /> + ( + + + + + + + )} + /> + + +
+ + + + + + +
+
+ +
+ {}}> + + + + {t("resourceAuthMethods")} + + + {t("resourcePolicyAuthMethodsDescription")} + + + + + {/* Password row */} +
+
+ + + {t("resourcePasswordProtection", { + status: hasPassword + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Pincode row */} +
+
+ + + {t("resourcePincodeProtection", { + status: hasPincode + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Header auth row */} +
+
+ + + {hasHeaderAuth + ? t( + "resourceHeaderAuthProtectionEnabled" + ) + : t( + "resourceHeaderAuthProtectionDisabled" + )} + +
+ +
+
+
+ +
+ +
+
+
+ + + ); +} diff --git a/src/components/resource-policy/EditPolicyForm.tsx b/src/components/resource-policy/EditPolicyForm.tsx index d138ff1a8..8b0376107 100644 --- a/src/components/resource-policy/EditPolicyForm.tsx +++ b/src/components/resource-policy/EditPolicyForm.tsx @@ -1,14 +1,6 @@ "use client"; -import { - SettingsContainer, - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionForm, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; +import { SettingsContainer } from "@app/components/Settings"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useOrgContext } from "@app/hooks/useOrgContext"; @@ -16,114 +8,19 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { orgQueries } from "@app/lib/queries"; -import { zodResolver } from "@hookform/resolvers/zod"; import { build } from "@server/build"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { UserType } from "@server/types/UserTypes"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import z from "zod"; - -import { type PolicyFormValues, createPolicySchema } from "."; -import { toast } from "@app/hooks/useToast"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { orgs, type ResourcePolicy } from "@server/db"; -import type { AxiosResponse } from "axios"; +import { createApiClient } from "@app/lib/api"; import { useRouter } from "next/navigation"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { Button } from "@app/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { InfoPopup } from "@app/components/ui/info-popup"; -import { Input } from "@app/components/ui/input"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { Switch } from "@app/components/ui/switch"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow -} from "@app/components/ui/table"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot -} from "@app/components/ui/input-otp"; - -import { MAJOR_ASNS } from "@server/db/asns"; -import { COUNTRIES } from "@server/db/countries"; -import { - isValidCIDR, - isValidIP, - isValidUrlGlobPattern -} from "@server/lib/validators"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable -} from "@tanstack/react-table"; -import { - ArrowUpDown, - Binary, - Bot, - Check, - ChevronsUpDown, - InfoIcon, - Key, - Plus -} from "lucide-react"; - -import { useCallback, useMemo, useState, useActionState } from "react"; -import { UseFormReturn, useForm, useWatch } from "react-hook-form"; -import router from "next/navigation"; -import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; -import { t } from "@faker-js/faker/dist/airline-CWrCIUHH"; +import { useMemo } from "react"; +import { EditPolicyAuthMethodsSectionForm } from "./EditPolicyAuthMethodsSectionForm"; +import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm"; +import { EditPolicyUsersRolesSectionForm } from "./EditPolicyUserRolesSectionForm"; // ─── EditPolicyForm ───────────────────────────────────────────────────────── @@ -161,77 +58,6 @@ export function EditPolicyForm({ hidePolicyNameForm }: EditPolicyFormProps) { }) ); - // const form = useForm({ - // resolver: zodResolver(createPolicySchema) as any, - // defaultValues: { - // name: "", - // sso: true, - // skipToIdpId: null, - // emailWhitelistEnabled: false, - // roles: [], - // users: [], - // emails: [], - // applyRules: false, - // rules: [], - // password: null, - // headerAuth: null, - // pincode: null - // } - // }); - - // async function onSubmit() { - // return; - // // const isValid = await form.trigger(); - - // // if (!isValid) return; - - // // const payload = form.getValues(); - - // // try { - // // const res = await api - // // .post>( - // // `/org/${org.org.orgId}/resource-policy/`, - // // { - // // name: payload.name, - // // sso: payload.sso, - // // roleIds: payload.roles.map((r) => r.id), - // // userIds: payload.users.map((u) => u.id) - // // } - // // ) - // // .catch((e) => { - // // toast({ - // // variant: "destructive", - // // title: t("policyErrorCreate"), - // // description: formatAxiosError( - // // e, - // // t("policyErrorCreateDescription") - // // ) - // // }); - // // }); - - // // if (res && res.status === 201) { - // // const id = res.data.data.resourcePolicyId; - // // const niceId = res.data.data.niceId; - - // // router.push(`/${org.org.orgId}/settings/policies/resources/`); - // // // should redirect to the details page - // // // router.push( - // // // `/${org.org.orgId}/settings/policies/resources/${niceId}` - // // // ); - // // toast({ - // // title: t("success"), - // // description: t("policyCreatedSuccess") - // // }); - // // } - // // } catch (e) { - // // toast({ - // // variant: "destructive", - // // title: t("policyErrorCreate"), - // // description: t("policyErrorCreateMessageDescription") - // // }); - // // } - // } - const allRoles = useMemo( () => orgRoles @@ -271,2069 +97,26 @@ export function EditPolicyForm({ hidePolicyNameForm }: EditPolicyFormProps) { } return ( - //
- // {/* Name */} - {!hidePolicyNameForm && } - } + - + + {/* - - */} + + */} - //
- // - ); -} - -// ─── PolicyNameSection ────────────────────────────────────────────────── - -export function PolicyNameSection() { - const t = useTranslations(); - const api = createApiClient(useEnvContext()); - - const { policy } = useResourcePolicyContext(); - const { org } = useOrgContext(); - const form = useForm({ - resolver: zodResolver( - z.object({ - name: z.string() - }) - ), - defaultValues: { - name: policy.name - } - }); - - const [, formAction, isSubmitting] = useActionState(onSubmit, null); - - async function onSubmit() { - const isValid = await form.trigger(); - - if (!isValid) return; - - const payload = form.getValues(); - - try { - const res = await api - .put>( - `/resource-policy/${policy.resourcePolicyId}`, - { - name: payload.name - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - }); - - if (res && res.status === 200) { - toast({ - title: t("success"), - description: t("policyUpdatedSuccess") - }); - } - } catch (e) { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: t("policyErrorUpdateMessageDescription") - }); - } - } - - return ( -
- - - - - {t("resourcePolicyName")} - - - {t("resourcePolicyNameDescription")} - - - - - ( - - {t("name")} - - - - - - )} - /> - - - -
- -
-
-
- - ); -} - -// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── - -type PolicyUsersRolesSectionProps = { - allRoles: { id: string; text: string }[]; - allUsers: { id: string; text: string }[]; - allIdps: { id: number; text: string }[]; -}; - -export function PolicyUsersRolesSection({ - allRoles, - allUsers, - allIdps -}: PolicyUsersRolesSectionProps) { - const t = useTranslations(); - - const { policy } = useResourcePolicyContext(); - - const api = createApiClient(useEnvContext()); - - const form = useForm({ - resolver: zodResolver( - createPolicySchema.pick({ - sso: true, - skipToIdpId: true, - users: true, - roles: true - }) - ), - defaultValues: { - sso: policy.sso, - skipToIdpId: policy.idpId, - roles: policy.roles.map((role) => ({ - id: role.roleId.toString(), - text: role.name - })), - users: policy.users.map((user) => ({ - id: user.userId, - text: `${getUserDisplayName({ email: user.email, username: user.username })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` - })) - } - }); - - const ssoEnabled = useWatch({ control: form.control, name: "sso" }); - const selectedIdpId = useWatch({ - control: form.control, - name: "skipToIdpId" - }); - const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< - number | null - >(null); - const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< - number | null - >(null); - - const [, formAction, isSubmitting] = useActionState(onSubmit, null); - - async function onSubmit() { - const isValid = await form.trigger(); - - if (!isValid) return; - - const payload = form.getValues(); - - try { - const res = await api - .put>( - `/resource-policy/${policy.resourcePolicyId}/access-control`, - { - sso: payload.sso, - userIds: payload.users.map((user) => user.id), - roleIds: payload.roles.map((role) => Number(role.id)), - skipToIdpId: payload.skipToIdpId - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - }); - - if (res && res.status === 200) { - toast({ - title: t("success"), - description: t("policyUpdatedSuccess") - }); - } - } catch (e) { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: t("policyErrorUpdateMessageDescription") - }); - } - } - - return ( -
- - - - - {t("resourceUsersRoles")} - - - {t("resourcePolicyUsersRolesDescription")} - - - - - { - console.log(`form.setValue("sso", ${val})`); - form.setValue("sso", val); - }} - /> - - {ssoEnabled && ( - <> - ( - - - {t("roles")} - - - { - form.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allRoles - } - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - {t( - "resourceRoleDescription" - )} - - - )} - /> - ( - - - {t("users")} - - - { - form.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allUsers - } - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - - )} - - {ssoEnabled && allIdps.length > 0 && ( -
- - -

- {t( - "defaultIdentityProviderDescription" - )} -

-
- )} -
-
- -
- -
-
-
- - ); -} - -// ─── PolicyAuthMethodsSection ───────────────────────────────────────────────── - -const setPasswordSchema = z.object({ - password: z.string().min(4).max(100) -}); - -const setPincodeSchema = z.object({ - pincode: z.string().length(6) -}); - -const setHeaderAuthSchema = z.object({ - user: z.string().min(4).max(100), - password: z.string().min(4).max(100), - extendedCompatibility: z.boolean() -}); - -export function PolicyAuthMethodsSection() { - const { policy } = useResourcePolicyContext(); - - const api = createApiClient(useEnvContext()); - - const form = useForm({ - resolver: zodResolver( - createPolicySchema.pick({ - password: true, - pincode: true, - headerAuth: true - }) - ), - defaultValues: {} - }); - - const t = useTranslations(); - const [isExpanded, setIsExpanded] = useState(false); - const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); - const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); - const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); - - const password = form.watch("password"); - const pincode = form.watch("pincode"); - const headerAuth = form.watch("headerAuth"); - - const passwordForm = useForm({ - resolver: zodResolver(setPasswordSchema), - defaultValues: { password: "" } - }); - - const pincodeForm = useForm({ - resolver: zodResolver(setPincodeSchema), - defaultValues: { pincode: "" } - }); - - const headerAuthForm = useForm({ - resolver: zodResolver(setHeaderAuthSchema), - defaultValues: { user: "", password: "", extendedCompatibility: true } - }); - - const [, formAction, isSubmitting] = useActionState(onSubmit, null); - - async function onSubmit() { - const isValid = await form.trigger(); - - if (!isValid) return; - - const payload = form.getValues(); - console.log({ payload }); - } - - if (!isExpanded) { - return ( - - - - {t("resourceAuthMethods")} - - - {t("resourcePolicyAuthMethodsDescription")} - - - - - - - ); - } - - return ( - <> - {/* Password Credenza */} - { - setIsSetPasswordOpen(val); - if (!val) passwordForm.reset(); - }} - > - - - - {t("resourcePasswordSetupTitle")} - - - {t("resourcePasswordSetupTitleDescription")} - - - -
- { - form.setValue("password", data); - setIsSetPasswordOpen(false); - passwordForm.reset(); - })} - className="space-y-4" - id="set-password-form" - > - ( - - - {t("password")} - - - - - - - )} - /> - - -
- - - - - - -
-
- - {/* Pincode Credenza */} - { - setIsSetPincodeOpen(val); - if (!val) pincodeForm.reset(); - }} - > - - - - {t("resourcePincodeSetupTitle")} - - - {t("resourcePincodeSetupTitleDescription")} - - - -
- { - form.setValue("pincode", data); - setIsSetPincodeOpen(false); - pincodeForm.reset(); - })} - className="space-y-4" - id="set-pincode-form" - > - ( - - - {t("resourcePincode")} - - -
- - - - - - - - - - -
-
- -
- )} - /> - - -
- - - - - - -
-
- - {/* Header Auth Credenza */} - { - setIsSetHeaderAuthOpen(val); - if (!val) headerAuthForm.reset(); - }} - > - - - - {t("resourceHeaderAuthSetupTitle")} - - - {t("resourceHeaderAuthSetupTitleDescription")} - - - -
- { - form.setValue("headerAuth", data); - setIsSetHeaderAuthOpen(false); - headerAuthForm.reset(); - } - )} - className="space-y-4" - id="set-header-auth-form" - > - ( - - {t("user")} - - - - - - )} - /> - ( - - - {t("password")} - - - - - - - )} - /> - ( - - - - - - - )} - /> - - -
- - - - - - -
-
- -
- {}}> - - - - {t("resourceAuthMethods")} - - - {t("resourcePolicyAuthMethodsDescription")} - - - - - {/* Password row */} -
-
- - - {t("resourcePasswordProtection", { - status: password - ? t("enabled") - : t("disabled") - })} - -
- -
- - {/* Pincode row */} -
-
- - - {t("resourcePincodeProtection", { - status: pincode - ? t("enabled") - : t("disabled") - })} - -
- -
- - {/* Header auth row */} -
-
- - - {headerAuth - ? t( - "resourceHeaderAuthProtectionEnabled" - ) - : t( - "resourceHeaderAuthProtectionDisabled" - )} - -
- -
-
-
- -
- -
-
-
- - - ); -} - -// ─── PolicyOtpEmailSection ──────────────────────────────────────────────────── - -type PolicyOtpEmailSectionProps = { - form: UseFormReturn; - emailEnabled: boolean; -}; - -export function PolicyOtpEmailSection({ - form, - emailEnabled -}: PolicyOtpEmailSectionProps) { - const t = useTranslations(); - const [isExpanded, setIsExpanded] = useState(false); - const [whitelistEnabled, setWhitelistEnabled] = useState(false); - const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< - number | null - >(null); - - if (!isExpanded) { - return ( - - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - - - - ); - } - - return ( - - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - - {!emailEnabled && ( - - - - {t("otpEmailSmtpRequired")} - - - {t("otpEmailSmtpRequiredDescription")} - - - )} - { - setWhitelistEnabled(val); - form.setValue("emailWhitelistEnabled", val); - }} - disabled={!emailEnabled} - /> - - {whitelistEnabled && emailEnabled && ( - ( - - - - - - {/* @ts-ignore */} - { - return z - .email() - .or( - z - .string() - .regex( - /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, - { - message: t( - "otpEmailErrorInvalid" - ) - } - ) - ) - .safeParse(tag).success; - }} - setActiveTagIndex={ - setActiveEmailTagIndex - } - placeholder={t("otpEmailEnter")} - tags={form.getValues().emails} - setTags={(newEmails) => { - form.setValue( - "emails", - newEmails as [Tag, ...Tag[]] - ); - }} - allowDuplicates={false} - sortTags={true} - /> - - - {t("otpEmailEnterDescription")} - - - )} - /> - )} - - - - ); -} - -// ─── PolicyRulesSection ─────────────────────────────────────────────────────── - -const addRuleSchema = z.object({ - action: z.enum(["ACCEPT", "DROP", "PASS"]), - match: z.string(), - value: z.string(), - priority: z.coerce.number().int().optional() -}); - -type LocalRule = { - ruleId: number; - action: "ACCEPT" | "DROP" | "PASS"; - match: string; - value: string; - priority: number; - enabled: boolean; - new?: boolean; - updated?: boolean; -}; - -type PolicyRulesSectionProps = { - form: UseFormReturn; - isMaxmindAvailable: boolean; - isMaxmindAsnAvailable: boolean; -}; - -export function PolicyRulesSection({ - form, - isMaxmindAvailable, - isMaxmindAsnAvailable -}: PolicyRulesSectionProps) { - const t = useTranslations(); - const [isExpanded, setIsExpanded] = useState(false); - const [rules, setRules] = useState([]); - const [rulesEnabled, setRulesEnabled] = useState(false); - const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = - useState(false); - const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); - - const addRuleForm = useForm({ - resolver: zodResolver(addRuleSchema), - defaultValues: { - action: "ACCEPT" as const, - match: "IP", - value: "" - } - }); - - const RuleAction = useMemo( - () => ({ - ACCEPT: t("alwaysAllow"), - DROP: t("alwaysDeny"), - PASS: t("passToAuth") - }), - [t] - ); - - const RuleMatch = useMemo( - () => ({ - PATH: t("path"), - IP: "IP", - CIDR: t("ipAddressRange"), - COUNTRY: t("country"), - ASN: "ASN" - }), - [t] - ); - - const syncFormRules = useCallback( - (updatedRules: LocalRule[]) => { - form.setValue( - "rules", - updatedRules.map( - ({ action, match, value, priority, enabled }) => ({ - action, - match, - value, - priority, - enabled - }) - ) - ); - }, - [form] - ); - - const addRule = useCallback( - function addRule(data: z.infer) { - const isDuplicate = rules.some( - (rule) => - rule.action === data.action && - rule.match === data.match && - rule.value === data.value - ); - if (isDuplicate) { - toast({ - variant: "destructive", - title: t("rulesErrorDuplicate"), - description: t("rulesErrorDuplicateDescription") - }); - return; - } - if (data.match === "CIDR" && !isValidCIDR(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidIpAddressRange"), - description: t("rulesErrorInvalidIpAddressRangeDescription") - }); - return; - } - if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidUrl"), - description: t("rulesErrorInvalidUrlDescription") - }); - return; - } - if (data.match === "IP" && !isValidIP(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidIpAddress"), - description: t("rulesErrorInvalidIpAddressDescription") - }); - return; - } - if ( - data.match === "COUNTRY" && - !COUNTRIES.some((c) => c.code === data.value) - ) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidCountry"), - description: t("rulesErrorInvalidCountryDescription") || "" - }); - return; - } - - let priority = data.priority; - if (priority === undefined) { - priority = - rules.reduce( - (acc, rule) => - rule.priority > acc ? rule.priority : acc, - 0 - ) + 1; - } - - const updatedRules = [ - ...rules, - { - ...data, - ruleId: new Date().getTime(), - new: true, - priority, - enabled: true - } - ]; - setRules(updatedRules); - syncFormRules(updatedRules); - addRuleForm.reset(); - }, - [rules, t, addRuleForm, syncFormRules] - ); - - const removeRule = useCallback( - function removeRule(ruleId: number) { - const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); - setRules(updatedRules); - syncFormRules(updatedRules); - }, - [rules, syncFormRules] - ); - - const updateRule = useCallback( - function updateRule(ruleId: number, data: Partial) { - const updatedRules = rules.map((rule) => - rule.ruleId === ruleId - ? { ...rule, ...data, updated: true } - : rule - ); - setRules(updatedRules); - syncFormRules(updatedRules); - }, - [rules, syncFormRules] - ); - - const getValueHelpText = useCallback( - function getValueHelpText(type: string) { - switch (type) { - case "CIDR": - return t("rulesMatchIpAddressRangeDescription"); - case "IP": - return t("rulesMatchIpAddress"); - case "PATH": - return t("rulesMatchUrl"); - case "COUNTRY": - return t("rulesMatchCountry"); - case "ASN": - return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; - } - }, - [t] - ); - - const columns: ColumnDef[] = useMemo( - () => [ - { - accessorKey: "priority", - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - e.currentTarget.focus()} - onBlur={(e) => { - const parsed = z.coerce - .number() - .int() - .optional() - .safeParse(e.target.value); - if (!parsed.success) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidPriority"), - description: t( - "rulesErrorInvalidPriorityDescription" - ) - }); - return; - } - updateRule(row.original.ruleId, { - priority: parsed.data - }); - }} - /> - ) - }, - { - accessorKey: "action", - header: () => {t("rulesAction")}, - cell: ({ row }) => ( - - ) - }, - { - accessorKey: "match", - header: () => ( - {t("rulesMatchType")} - ), - cell: ({ row }) => ( - - ) - }, - { - accessorKey: "value", - header: () => {t("value")}, - cell: ({ row }) => - row.original.match === "COUNTRY" ? ( - - - - - - - - - - {t("noCountryFound")} - - - {COUNTRIES.map((country) => ( - - updateRule( - row.original.ruleId, - { - value: country.code - } - ) - } - > - - {country.name} ( - {country.code}) - - ))} - - - - - - ) : row.original.match === "ASN" ? ( - - - - - - - - - - No ASN found. Enter a custom ASN - below. - - - {MAJOR_ASNS.map((asn) => ( - - updateRule( - row.original.ruleId, - { value: asn.code } - ) - } - > - - {asn.name} ({asn.code}) - - ))} - - - -
- - asn.code === - row.original.value - ) - ? row.original.value - : "" - } - onKeyDown={(e) => { - if (e.key === "Enter") { - const value = - e.currentTarget.value - .toUpperCase() - .replace(/^AS/, ""); - if (/^\d+$/.test(value)) { - updateRule( - row.original.ruleId, - { value: "AS" + value } - ); - } - } - }} - className="text-sm" - /> -
-
-
- ) : ( - - updateRule(row.original.ruleId, { - value: e.target.value - }) - } - /> - ) - }, - { - accessorKey: "enabled", - header: () => {t("enabled")}, - cell: ({ row }) => ( - - updateRule(row.original.ruleId, { enabled: val }) - } - /> - ) - }, - { - id: "actions", - header: () => {t("actions")}, - cell: ({ row }) => ( -
- -
- ) - } - ], - [ - t, - RuleAction, - RuleMatch, - isMaxmindAvailable, - isMaxmindAsnAvailable, - updateRule, - removeRule - ] - ); - - const table = useReactTable({ - data: rules, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - state: { pagination: { pageIndex: 0, pageSize: 1000 } } - }); - - if (!isExpanded) { - return ( - - - - {t("rulesResource")} - - - {t("rulesResourcePolicyDescription")} - - - - - - - ); - } - - return ( - - - - {t("rulesResource")} - - - {t("rulesResourceDescription")} - - - -
-
- { - setRulesEnabled(val); - form.setValue("applyRules", val); - }} - /> -
- -
- -
- ( - - - {t("rulesAction")} - - - - - - - )} - /> - ( - - - {t("rulesMatchType")} - - - - - - - )} - /> - ( - - - - {addRuleForm.watch("match") === - "COUNTRY" ? ( - - - - - - - - - - {t( - "noCountryFound" - )} - - - {COUNTRIES.map( - ( - country - ) => ( - { - field.onChange( - country.code - ); - setOpenAddRuleCountrySelect( - false - ); - }} - > - - { - country.name - }{" "} - ( - { - country.code - } - - ) - - ) - )} - - - - - - ) : addRuleForm.watch( - "match" - ) === "ASN" ? ( - - - - - - - - - - No ASN - found. - Use the - custom - input - below. - - - {MAJOR_ASNS.map( - ( - asn - ) => ( - { - field.onChange( - asn.code - ); - setOpenAddRuleAsnSelect( - false - ); - }} - > - - { - asn.name - }{" "} - ( - { - asn.code - } - - ) - - ) - )} - - - -
- { - if ( - e.key === - "Enter" - ) { - const value = - e.currentTarget.value - .toUpperCase() - .replace( - /^AS/, - "" - ); - if ( - /^\d+$/.test( - value - ) - ) { - field.onChange( - "AS" + - value - ); - setOpenAddRuleAsnSelect( - false - ); - } - } - }} - className="text-sm" - /> -
-
-
- ) : ( - - )} -
- -
- )} - /> - -
-
- - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const isActionsColumn = - header.column.id === "actions"; - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column - .columnDef.header, - header.getContext() - )} - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => { - const isActionsColumn = - cell.column.id === "actions"; - return ( - - {flexRender( - cell.column.columnDef - .cell, - cell.getContext() - )} - - ); - })} - - )) - ) : ( - - - {t("rulesNoOne")} - - - )} - -
-
-
-
); } diff --git a/src/components/resource-policy/EditPolicyNameSectionForm.tsx b/src/components/resource-policy/EditPolicyNameSectionForm.tsx new file mode 100644 index 000000000..1d45b9d3e --- /dev/null +++ b/src/components/resource-policy/EditPolicyNameSectionForm.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { useEnvContext } from "@app/hooks/useEnvContext"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; + +import z from "zod"; + +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { type ResourcePolicy } from "@server/db"; +import type { AxiosResponse } from "axios"; +import { useRouter } from "next/navigation"; + +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; + +import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; +import { useActionState } from "react"; +import { useForm } from "react-hook-form"; + +// ─── PolicyNameSection ────────────────────────────────────────────────── + +export function EditPolicyNameSectionForm() { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const router = useRouter(); + + const { policy } = useResourcePolicyContext(); + + const form = useForm({ + resolver: zodResolver( + z.object({ + name: z.string() + }) + ), + defaultValues: { + name: policy.name + } + }); + + const [, formAction, isSubmitting] = useActionState(onSubmit, null); + + async function onSubmit() { + const isValid = await form.trigger(); + + if (!isValid) return; + + const payload = form.getValues(); + + try { + const res = await api + .put>( + `/resource-policy/${policy.resourcePolicyId}`, + { + name: payload.name + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }); + + if (res && res.status === 200) { + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: t("policyErrorUpdateMessageDescription") + }); + } + } + + return ( +
+ + + + + {t("resourcePolicyName")} + + + {t("resourcePolicyNameDescription")} + + + + + ( + + {t("name")} + + + + + + )} + /> + + + +
+ +
+
+
+ + ); +} diff --git a/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx b/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx new file mode 100644 index 000000000..917001158 --- /dev/null +++ b/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { useTranslations } from "next-intl"; + +import z from "zod"; + +import { type PolicyFormValues } from "."; + +import { SwitchInput } from "@app/components/SwitchInput"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Button } from "@app/components/ui/button"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel +} from "@app/components/ui/form"; +import { InfoPopup } from "@app/components/ui/info-popup"; + +import { InfoIcon, Plus } from "lucide-react"; + +import { useState } from "react"; +import { UseFormReturn } from "react-hook-form"; + +// ─── PolicyOtpEmailSection ──────────────────────────────────────────────────── + +type PolicyOtpEmailSectionProps = { + form: UseFormReturn; + emailEnabled: boolean; +}; + +export function PolicyOtpEmailSection({ + form, + emailEnabled +}: PolicyOtpEmailSectionProps) { + const t = useTranslations(); + const [isExpanded, setIsExpanded] = useState(false); + const [whitelistEnabled, setWhitelistEnabled] = useState(false); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + + if (!isExpanded) { + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + + + ); + } + + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + {!emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + { + setWhitelistEnabled(val); + form.setValue("emailWhitelistEnabled", val); + }} + disabled={!emailEnabled} + /> + + {whitelistEnabled && emailEnabled && ( + ( + + + + + + {/* @ts-ignore */} + { + return z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: t( + "otpEmailErrorInvalid" + ) + } + ) + ) + .safeParse(tag).success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder={t("otpEmailEnter")} + tags={form.getValues().emails} + setTags={(newEmails) => { + form.setValue( + "emails", + newEmails as [Tag, ...Tag[]] + ); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t("otpEmailEnterDescription")} + + + )} + /> + )} + + + + ); +} diff --git a/src/components/resource-policy/EditPolicyRulesSectionForm.tsx b/src/components/resource-policy/EditPolicyRulesSectionForm.tsx new file mode 100644 index 000000000..692cbf463 --- /dev/null +++ b/src/components/resource-policy/EditPolicyRulesSectionForm.tsx @@ -0,0 +1,1113 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; + +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { orgQueries } from "@app/lib/queries"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { build } from "@server/build"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { UserType } from "@server/types/UserTypes"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; + +import z from "zod"; + +import { type PolicyFormValues, createPolicySchema } from "."; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { orgs, type ResourcePolicy } from "@server/db"; +import type { AxiosResponse } from "axios"; +import { useRouter } from "next/navigation"; + +import { SwitchInput } from "@app/components/SwitchInput"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { Input } from "@app/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { Switch } from "@app/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot +} from "@app/components/ui/input-otp"; + +import { MAJOR_ASNS } from "@server/db/asns"; +import { COUNTRIES } from "@server/db/countries"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable +} from "@tanstack/react-table"; +import { + ArrowUpDown, + Binary, + Bot, + Check, + ChevronsUpDown, + InfoIcon, + Key, + Plus +} from "lucide-react"; + +import { useCallback, useMemo, useState, useActionState } from "react"; +import { UseFormReturn, useForm, useWatch } from "react-hook-form"; +import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; +import { cn } from "@app/lib/cn"; + +// ─── PolicyRulesSection ─────────────────────────────────────────────────────── + +const addRuleSchema = z.object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.coerce.number().int().optional() +}); + +type LocalRule = { + ruleId: number; + action: "ACCEPT" | "DROP" | "PASS"; + match: string; + value: string; + priority: number; + enabled: boolean; + new?: boolean; + updated?: boolean; +}; + +type PolicyRulesSectionProps = { + form: UseFormReturn; + isMaxmindAvailable: boolean; + isMaxmindAsnAvailable: boolean; +}; + +export function PolicyRulesSection({ + form, + isMaxmindAvailable, + isMaxmindAsnAvailable +}: PolicyRulesSectionProps) { + const t = useTranslations(); + const [isExpanded, setIsExpanded] = useState(false); + const [rules, setRules] = useState([]); + const [rulesEnabled, setRulesEnabled] = useState(false); + const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = + useState(false); + const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); + + const addRuleForm = useForm({ + resolver: zodResolver(addRuleSchema), + defaultValues: { + action: "ACCEPT" as const, + match: "IP", + value: "" + } + }); + + const RuleAction = useMemo( + () => ({ + ACCEPT: t("alwaysAllow"), + DROP: t("alwaysDeny"), + PASS: t("passToAuth") + }), + [t] + ); + + const RuleMatch = useMemo( + () => ({ + PATH: t("path"), + IP: "IP", + CIDR: t("ipAddressRange"), + COUNTRY: t("country"), + ASN: "ASN" + }), + [t] + ); + + const syncFormRules = useCallback( + (updatedRules: LocalRule[]) => { + form.setValue( + "rules", + updatedRules.map( + ({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + }) + ) + ); + }, + [form] + ); + + const addRule = useCallback( + function addRule(data: z.infer) { + const isDuplicate = rules.some( + (rule) => + rule.action === data.action && + rule.match === data.match && + rule.value === data.value + ); + if (isDuplicate) { + toast({ + variant: "destructive", + title: t("rulesErrorDuplicate"), + description: t("rulesErrorDuplicateDescription") + }); + return; + } + if (data.match === "CIDR" && !isValidCIDR(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddressRange"), + description: t("rulesErrorInvalidIpAddressRangeDescription") + }); + return; + } + if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidUrl"), + description: t("rulesErrorInvalidUrlDescription") + }); + return; + } + if (data.match === "IP" && !isValidIP(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddress"), + description: t("rulesErrorInvalidIpAddressDescription") + }); + return; + } + if ( + data.match === "COUNTRY" && + !COUNTRIES.some((c) => c.code === data.value) + ) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidCountry"), + description: t("rulesErrorInvalidCountryDescription") || "" + }); + return; + } + + let priority = data.priority; + if (priority === undefined) { + priority = + rules.reduce( + (acc, rule) => + rule.priority > acc ? rule.priority : acc, + 0 + ) + 1; + } + + const updatedRules = [ + ...rules, + { + ...data, + ruleId: new Date().getTime(), + new: true, + priority, + enabled: true + } + ]; + setRules(updatedRules); + syncFormRules(updatedRules); + addRuleForm.reset(); + }, + [rules, t, addRuleForm, syncFormRules] + ); + + const removeRule = useCallback( + function removeRule(ruleId: number) { + const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules] + ); + + const updateRule = useCallback( + function updateRule(ruleId: number, data: Partial) { + const updatedRules = rules.map((rule) => + rule.ruleId === ruleId + ? { ...rule, ...data, updated: true } + : rule + ); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules] + ); + + const getValueHelpText = useCallback( + function getValueHelpText(type: string) { + switch (type) { + case "CIDR": + return t("rulesMatchIpAddressRangeDescription"); + case "IP": + return t("rulesMatchIpAddress"); + case "PATH": + return t("rulesMatchUrl"); + case "COUNTRY": + return t("rulesMatchCountry"); + case "ASN": + return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; + } + }, + [t] + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + accessorKey: "priority", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + e.currentTarget.focus()} + onBlur={(e) => { + const parsed = z.coerce + .number() + .int() + .optional() + .safeParse(e.target.value); + if (!parsed.success) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidPriority"), + description: t( + "rulesErrorInvalidPriorityDescription" + ) + }); + return; + } + updateRule(row.original.ruleId, { + priority: parsed.data + }); + }} + /> + ) + }, + { + accessorKey: "action", + header: () => {t("rulesAction")}, + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "match", + header: () => ( + {t("rulesMatchType")} + ), + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "value", + header: () => {t("value")}, + cell: ({ row }) => + row.original.match === "COUNTRY" ? ( + + + + + + + + + + {t("noCountryFound")} + + + {COUNTRIES.map((country) => ( + + updateRule( + row.original.ruleId, + { + value: country.code + } + ) + } + > + + {country.name} ( + {country.code}) + + ))} + + + + + + ) : row.original.match === "ASN" ? ( + + + + + + + + + + No ASN found. Enter a custom ASN + below. + + + {MAJOR_ASNS.map((asn) => ( + + updateRule( + row.original.ruleId, + { value: asn.code } + ) + } + > + + {asn.name} ({asn.code}) + + ))} + + + +
+ + asn.code === + row.original.value + ) + ? row.original.value + : "" + } + onKeyDown={(e) => { + if (e.key === "Enter") { + const value = + e.currentTarget.value + .toUpperCase() + .replace(/^AS/, ""); + if (/^\d+$/.test(value)) { + updateRule( + row.original.ruleId, + { value: "AS" + value } + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + updateRule(row.original.ruleId, { + value: e.target.value + }) + } + /> + ) + }, + { + accessorKey: "enabled", + header: () => {t("enabled")}, + cell: ({ row }) => ( + + updateRule(row.original.ruleId, { enabled: val }) + } + /> + ) + }, + { + id: "actions", + header: () => {t("actions")}, + cell: ({ row }) => ( +
+ +
+ ) + } + ], + [ + t, + RuleAction, + RuleMatch, + isMaxmindAvailable, + isMaxmindAsnAvailable, + updateRule, + removeRule + ] + ); + + const table = useReactTable({ + data: rules, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { pagination: { pageIndex: 0, pageSize: 1000 } } + }); + + if (!isExpanded) { + return ( + + + + {t("rulesResource")} + + + {t("rulesResourcePolicyDescription")} + + + + + + + ); + } + + return ( + + + + {t("rulesResource")} + + + {t("rulesResourceDescription")} + + + +
+
+ { + setRulesEnabled(val); + form.setValue("applyRules", val); + }} + /> +
+ +
+ +
+ ( + + + {t("rulesAction")} + + + + + + + )} + /> + ( + + + {t("rulesMatchType")} + + + + + + + )} + /> + ( + + + + {addRuleForm.watch("match") === + "COUNTRY" ? ( + + + + + + + + + + {t( + "noCountryFound" + )} + + + {COUNTRIES.map( + ( + country + ) => ( + { + field.onChange( + country.code + ); + setOpenAddRuleCountrySelect( + false + ); + }} + > + + { + country.name + }{" "} + ( + { + country.code + } + + ) + + ) + )} + + + + + + ) : addRuleForm.watch( + "match" + ) === "ASN" ? ( + + + + + + + + + + No ASN + found. + Use the + custom + input + below. + + + {MAJOR_ASNS.map( + ( + asn + ) => ( + { + field.onChange( + asn.code + ); + setOpenAddRuleAsnSelect( + false + ); + }} + > + + { + asn.name + }{" "} + ( + { + asn.code + } + + ) + + ) + )} + + + +
+ { + if ( + e.key === + "Enter" + ) { + const value = + e.currentTarget.value + .toUpperCase() + .replace( + /^AS/, + "" + ); + if ( + /^\d+$/.test( + value + ) + ) { + field.onChange( + "AS" + + value + ); + setOpenAddRuleAsnSelect( + false + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + )} +
+ +
+ )} + /> + +
+
+ + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isActionsColumn = + header.column.id === "actions"; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column + .columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const isActionsColumn = + cell.column.id === "actions"; + return ( + + {flexRender( + cell.column.columnDef + .cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + + {t("rulesNoOne")} + + + )} + +
+
+
+
+ ); +} diff --git a/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx b/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx new file mode 100644 index 000000000..19c5bb719 --- /dev/null +++ b/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx @@ -0,0 +1,358 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { useEnvContext } from "@app/hooks/useEnvContext"; + +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { UserType } from "@server/types/UserTypes"; +import { useTranslations } from "next-intl"; + +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import type { AxiosResponse } from "axios"; +import { useRouter } from "next/navigation"; +import { createPolicySchema } from "."; + +import { SwitchInput } from "@app/components/SwitchInput"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; + +import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; +import { useActionState, useState } from "react"; +import { useForm, useWatch } from "react-hook-form"; + +// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── + +type PolicyUsersRolesSectionProps = { + allRoles: { id: string; text: string }[]; + allUsers: { id: string; text: string }[]; + allIdps: { id: number; text: string }[]; +}; + +export function EditPolicyUsersRolesSectionForm({ + allRoles, + allUsers, + allIdps +}: PolicyUsersRolesSectionProps) { + const t = useTranslations(); + + const router = useRouter(); + + const { policy } = useResourcePolicyContext(); + + const api = createApiClient(useEnvContext()); + + const form = useForm({ + resolver: zodResolver( + createPolicySchema.pick({ + sso: true, + skipToIdpId: true, + users: true, + roles: true + }) + ), + defaultValues: { + sso: policy.sso, + skipToIdpId: policy.idpId, + roles: policy.roles.map((role) => ({ + id: role.roleId.toString(), + text: role.name + })), + users: policy.users.map((user) => ({ + id: user.userId, + text: `${getUserDisplayName({ email: user.email, username: user.username })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` + })) + } + }); + + const ssoEnabled = useWatch({ control: form.control, name: "sso" }); + const selectedIdpId = useWatch({ + control: form.control, + name: "skipToIdpId" + }); + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< + number | null + >(null); + const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< + number | null + >(null); + + const [, formAction, isSubmitting] = useActionState(onSubmit, null); + + async function onSubmit() { + const isValid = await form.trigger(); + + if (!isValid) return; + + const payload = form.getValues(); + + try { + const res = await api + .put>( + `/resource-policy/${policy.resourcePolicyId}/access-control`, + { + sso: payload.sso, + userIds: payload.users.map((user) => user.id), + roleIds: payload.roles.map((role) => Number(role.id)), + skipToIdpId: payload.skipToIdpId + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }); + + if (res && res.status === 200) { + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: t("policyErrorUpdateMessageDescription") + }); + } + } + + return ( +
+ + + + + {t("resourceUsersRoles")} + + + {t("resourcePolicyUsersRolesDescription")} + + + + + { + console.log(`form.setValue("sso", ${val})`); + form.setValue("sso", val); + }} + /> + + {ssoEnabled && ( + <> + ( + + + {t("roles")} + + + { + form.setValue( + "roles", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allRoles + } + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + {t( + "resourceRoleDescription" + )} + + + )} + /> + ( + + + {t("users")} + + + { + form.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allUsers + } + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + + )} + + {ssoEnabled && allIdps.length > 0 && ( +
+ + +

+ {t( + "defaultIdentityProviderDescription" + )} +

+
+ )} +
+
+ +
+ +
+
+
+ + ); +} From 1dc8be373c6f8e35b314175d491e22c0223a24a5 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 3 Mar 2026 18:54:35 +0100 Subject: [PATCH 43/89] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20add=20password?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EditPolicyAuthMethodsSectionForm.tsx | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx b/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx index 25db214e7..99d01930a 100644 --- a/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx @@ -16,7 +16,7 @@ import { useTranslations } from "next-intl"; import z from "zod"; -import { createApiClient } from "@app/lib/api"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useRouter } from "next/navigation"; import { createPolicySchema } from "."; @@ -53,6 +53,8 @@ import { cn } from "@app/lib/cn"; import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; import { useActionState, useState } from "react"; import { useForm } from "react-hook-form"; +import { toast } from "@app/hooks/useToast"; +import type { AxiosResponse } from "axios"; // ─── PolicyAuthMethodsSection ───────────────────────────────────────────────── @@ -87,7 +89,6 @@ export function EditPolicyAuthMethodsSectionForm() { }); const t = useTranslations(); - const [isExpanded, setIsExpanded] = useState(false); const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); @@ -98,6 +99,10 @@ export function EditPolicyAuthMethodsSectionForm() { form.watch("headerAuth") ?? policy.headerAuth ); + const [isExpanded, setIsExpanded] = useState( + hasPassword || hasPincode || hasHeaderAuth + ); + const passwordForm = useForm({ resolver: zodResolver(setPasswordSchema), defaultValues: { password: "" } @@ -121,7 +126,43 @@ export function EditPolicyAuthMethodsSectionForm() { if (!isValid) return; const payload = form.getValues(); - console.log({ payload }); + console.log({ payload, policy }); + + return; + + try { + const res = await api + .put>( + `/resource-policy/${policy.resourcePolicyId}/password`, + { + password: payload.password?.password ?? null + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }); + + if (res && res.status === 200) { + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: t("policyErrorUpdateMessageDescription") + }); + } } if (!isExpanded) { @@ -407,7 +448,7 @@ export function EditPolicyAuthMethodsSectionForm() {
- {}}> + From 20b65f549e5b160312e4a2265fc02591f4976dce Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 3 Mar 2026 19:49:24 +0100 Subject: [PATCH 44/89] =?UTF-8?q?=E2=9C=A8=20Update=20resource=20policy=20?= =?UTF-8?q?pincode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../policy/setResourcePolicyHeaderAuth.ts | 20 ++-- .../EditPolicyAuthMethodsSectionForm.tsx | 108 ++++++++++++++---- 2 files changed, 97 insertions(+), 31 deletions(-) diff --git a/server/routers/policy/setResourcePolicyHeaderAuth.ts b/server/routers/policy/setResourcePolicyHeaderAuth.ts index 34c55b750..6291ee24e 100644 --- a/server/routers/policy/setResourcePolicyHeaderAuth.ts +++ b/server/routers/policy/setResourcePolicyHeaderAuth.ts @@ -15,9 +15,13 @@ const setResourcePolicyHeaderAuthParamsSchema = z.object({ }); const setResourcePolicyHeaderAuthBodySchema = z.strictObject({ - user: z.string().min(4).max(100).nullable(), - password: z.string().min(4).max(100).nullable(), - extendedCompatibility: z.boolean().nullable() + headerAuth: z + .object({ + user: z.string().min(4).max(100), + password: z.string().min(4).max(100), + extendedCompatibility: z.boolean() + }) + .nullable() }); registry.registerPath({ @@ -70,7 +74,7 @@ export async function setResourcePolicyHeaderAuth( } const { resourcePolicyId } = parsedParams.data; - const { user, password, extendedCompatibility } = parsedBody.data; + const { headerAuth } = parsedBody.data; await db.transaction(async (trx) => { await trx @@ -82,15 +86,17 @@ export async function setResourcePolicyHeaderAuth( ) ); - if (user && password && extendedCompatibility !== null) { + if (headerAuth !== null) { const headerAuthHash = await hashPassword( - Buffer.from(`${user}:${password}`).toString("base64") + Buffer.from( + `${headerAuth.user}:${headerAuth.password}` + ).toString("base64") ); await trx.insert(resourcePolicyHeaderAuth).values({ resourcePolicyId, headerAuthHash, - extendedCompatibility: extendedCompatibility + extendedCompatibility: headerAuth.extendedCompatibility }); } }); diff --git a/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx b/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx index 99d01930a..957d60978 100644 --- a/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx @@ -93,11 +93,21 @@ export function EditPolicyAuthMethodsSectionForm() { const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); - const hasPassword = Boolean(form.watch("password") ?? policy.passwordId); - const hasPincode = Boolean(form.watch("pincode") ?? policy.pincodeId); - const hasHeaderAuth = Boolean( - form.watch("headerAuth") ?? policy.headerAuth - ); + const password = form.watch("password"); + const pincode = form.watch("pincode"); + const headerAuth = form.watch("headerAuth"); + + // If explicitly removed (set to `null`) it means the value has been removed + // in the other case (`undefined` or object value), check if the value has been modified + // and fallback to the policy default value + const hasPassword = + password !== null ? Boolean(password ?? policy.passwordId) : false; + + const hasPincode = + pincode !== null ? Boolean(pincode ?? policy.pincodeId) : false; + + const hasHeaderAuth = + headerAuth !== null ? Boolean(headerAuth ?? policy.headerAuth) : false; const [isExpanded, setIsExpanded] = useState( hasPassword || hasPincode || hasHeaderAuth @@ -128,28 +138,78 @@ export function EditPolicyAuthMethodsSectionForm() { const payload = form.getValues(); console.log({ payload, policy }); - return; + const responseArray: Array | void>> = []; + + if (typeof payload.password !== "undefined") { + responseArray.push( + api + .put>( + `/resource-policy/${policy.resourcePolicyId}/password`, + { + password: payload.password?.password ?? null + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }) + ); + } + + if (typeof payload.pincode !== "undefined") { + responseArray.push( + api + .put>( + `/resource-policy/${policy.resourcePolicyId}/pincode`, + { + pincode: payload.pincode?.pincode ?? null + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }) + ); + } + + if (typeof payload.headerAuth !== "undefined") { + responseArray.push( + api + .put>( + `/resource-policy/${policy.resourcePolicyId}/header-auth`, + { + headerAuth: payload.headerAuth + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }) + ); + } try { - const res = await api - .put>( - `/resource-policy/${policy.resourcePolicyId}/password`, - { - password: payload.password?.password ?? null - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - }); + const responseList = await Promise.all(responseArray); - if (res && res.status === 200) { + if (responseList.every((res) => res && res.status === 200)) { toast({ title: t("success"), description: t("policyUpdatedSuccess") From be2b1fd1ce55182054ca728ef2af337c0165b216 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 3 Mar 2026 20:26:17 +0100 Subject: [PATCH 45/89] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20email=20whitelist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.mail.yml | 13 + server/db/pg/schema/schema.ts | 22 +- server/routers/policy/getResourcePolicy.ts | 15 +- .../EditPolicyAuthMethodsSectionForm.tsx | 6 +- .../resource-policy/EditPolicyForm.tsx | 9 +- .../EditPolicyNameSectionForm.tsx | 5 +- .../EditPolicyOtpEmailSectionForm.tsx | 270 +++++++++++------- .../EditPolicyUserRolesSectionForm.tsx | 5 +- 8 files changed, 220 insertions(+), 125 deletions(-) create mode 100644 docker-compose.mail.yml diff --git a/docker-compose.mail.yml b/docker-compose.mail.yml new file mode 100644 index 000000000..49aaead9f --- /dev/null +++ b/docker-compose.mail.yml @@ -0,0 +1,13 @@ +services: + mailer: + image: axllent/mailpit + ports: + - 8025:8025 + - 1025:1025 + volumes: + - mailpit-storage:/data + environment: + - MP_DATABASE=/data/mailpit.db + +volumes: + mailpit-storage: diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 935fab7c1..80fa24ac9 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -469,6 +469,16 @@ export const userPolicies = pgTable("userPolicies", { }) }); +export const resourcePolicyWhiteList = pgTable("resourcePolicyWhitelist", { + whitelistId: serial("id").primaryKey(), + email: varchar("email").notNull(), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) +}); + export const userInvites = pgTable("userInvites", { inviteId: varchar("inviteId").primaryKey(), orgId: varchar("orgId") @@ -621,12 +631,7 @@ export const resourceWhitelist = pgTable("resourceWhitelist", { email: varchar("email").notNull(), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }), - resourcePolicyId: integer("resourcePolicyId") - .notNull() - .references(() => resourcePolicies.resourcePolicyId, { - onDelete: "cascade" - }) + .references(() => resources.resourceId, { onDelete: "cascade" }) }); export const resourceOtp = pgTable("resourceOtp", { @@ -634,11 +639,6 @@ export const resourceOtp = pgTable("resourceOtp", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), - resourcePolicyId: integer("resourcePolicyId") - .notNull() - .references(() => resourcePolicies.resourcePolicyId, { - onDelete: "cascade" - }), email: varchar("email").notNull(), otpHash: varchar("otpHash").notNull(), expiresAt: bigint("expiresAt", { mode: "number" }).notNull() diff --git a/server/routers/policy/getResourcePolicy.ts b/server/routers/policy/getResourcePolicy.ts index cba2a9dba..124f67718 100644 --- a/server/routers/policy/getResourcePolicy.ts +++ b/server/routers/policy/getResourcePolicy.ts @@ -5,6 +5,7 @@ import { resourcePolicyHeaderAuth, resourcePolicyPassword, resourcePolicyPincode, + resourcePolicyWhiteList, rolePolicies, roles, userPolicies, @@ -116,7 +117,19 @@ async function query(params: z.infer) { ) .where(eq(rolePolicies.resourcePolicyId, res.resourcePolicyId)); - return { ...res, roles: policyRoles, users: policyUsers }; + const policyEmailWhiteList = await db + .select() + .from(resourcePolicyWhiteList) + .where( + eq(resourcePolicyWhiteList.resourcePolicyId, res.resourcePolicyId) + ); + + return { + ...res, + roles: policyRoles, + users: policyUsers, + emailWhiteList: policyEmailWhiteList + }; } export type GetResourcePolicyResponse = NonNullable< diff --git a/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx b/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx index 957d60978..8a7efce1d 100644 --- a/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx @@ -4,6 +4,7 @@ import { SettingsSection, SettingsSectionBody, SettingsSectionDescription, + SettingsSectionFooter, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle @@ -136,7 +137,6 @@ export function EditPolicyAuthMethodsSectionForm() { if (!isValid) return; const payload = form.getValues(); - console.log({ payload, policy }); const responseArray: Array | void>> = []; @@ -640,7 +640,7 @@ export function EditPolicyAuthMethodsSectionForm() { -
+ -
+
diff --git a/src/components/resource-policy/EditPolicyForm.tsx b/src/components/resource-policy/EditPolicyForm.tsx index 8b0376107..4647c84f1 100644 --- a/src/components/resource-policy/EditPolicyForm.tsx +++ b/src/components/resource-policy/EditPolicyForm.tsx @@ -21,6 +21,7 @@ import { useMemo } from "react"; import { EditPolicyAuthMethodsSectionForm } from "./EditPolicyAuthMethodsSectionForm"; import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm"; import { EditPolicyUsersRolesSectionForm } from "./EditPolicyUserRolesSectionForm"; +import { EditPolicyOtpEmailSectionForm } from "./EditPolicyOtpEmailSectionForm"; // ─── EditPolicyForm ───────────────────────────────────────────────────────── @@ -107,11 +108,11 @@ export function EditPolicyForm({ hidePolicyNameForm }: EditPolicyFormProps) { /> + + {/* - -
+ -
+ diff --git a/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx b/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx index 917001158..93cb2b295 100644 --- a/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx @@ -4,6 +4,7 @@ import { SettingsSection, SettingsSectionBody, SettingsSectionDescription, + SettingsSectionFooter, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle @@ -13,13 +14,14 @@ import { useTranslations } from "next-intl"; import z from "zod"; -import { type PolicyFormValues } from "."; +import { createPolicySchema, type PolicyFormValues } from "."; import { SwitchInput } from "@app/components/SwitchInput"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; import { + Form, FormControl, FormDescription, FormField, @@ -30,27 +32,64 @@ import { InfoPopup } from "@app/components/ui/info-popup"; import { InfoIcon, Plus } from "lucide-react"; -import { useState } from "react"; -import { UseFormReturn } from "react-hook-form"; +import { useActionState, useState } from "react"; +import { useForm, UseFormReturn, useWatch } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; // ─── PolicyOtpEmailSection ──────────────────────────────────────────────────── type PolicyOtpEmailSectionProps = { - form: UseFormReturn; emailEnabled: boolean; }; -export function PolicyOtpEmailSection({ - form, +export function EditPolicyOtpEmailSectionForm({ emailEnabled }: PolicyOtpEmailSectionProps) { const t = useTranslations(); - const [isExpanded, setIsExpanded] = useState(false); - const [whitelistEnabled, setWhitelistEnabled] = useState(false); + + const { policy } = useResourcePolicyContext(); + const router = useRouter(); + + const form = useForm({ + resolver: zodResolver( + createPolicySchema.pick({ + emailWhitelistEnabled: true, + emails: true + }) + ), + defaultValues: { + emailWhitelistEnabled: policy.emailWhitelistEnabled, + emails: policy.emailWhiteList.map((email) => ({ + id: email.whitelistId.toString(), + text: email.email + })) + } + }); + + const whitelistEnabled = useWatch({ + control: form.control, + name: "emailWhitelistEnabled" + }); + + const [isExpanded, setIsExpanded] = useState(whitelistEnabled); const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< number | null >(null); + const [, formAction, isSubmitting] = useActionState(onSubmit, null); + + async function onSubmit() { + const isValid = await form.trigger(); + + if (!isValid) return; + + const payload = form.getValues(); + + console.log({ payload, policy }); + } + if (!isExpanded) { return ( @@ -77,100 +116,127 @@ export function PolicyOtpEmailSection({ } return ( - - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - - {!emailEnabled && ( - - - - {t("otpEmailSmtpRequired")} - - - {t("otpEmailSmtpRequiredDescription")} - - - )} - { - setWhitelistEnabled(val); - form.setValue("emailWhitelistEnabled", val); - }} - disabled={!emailEnabled} - /> - - {whitelistEnabled && emailEnabled && ( - ( - - - - - - {/* @ts-ignore */} - { - return z - .email() - .or( - z - .string() - .regex( - /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, - { - message: t( - "otpEmailErrorInvalid" - ) - } - ) - ) - .safeParse(tag).success; - }} - setActiveTagIndex={ - setActiveEmailTagIndex - } - placeholder={t("otpEmailEnter")} - tags={form.getValues().emails} - setTags={(newEmails) => { - form.setValue( - "emails", - newEmails as [Tag, ...Tag[]] - ); - }} - allowDuplicates={false} - sortTags={true} - /> - - - {t("otpEmailEnterDescription")} - - +
+ + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + {!emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + )} - /> - )} - - - + { + form.setValue("emailWhitelistEnabled", val); + }} + disabled={!emailEnabled} + /> + + {whitelistEnabled && emailEnabled && ( + ( + + + + + + {/* @ts-ignore */} + { + return z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: + t( + "otpEmailErrorInvalid" + ) + } + ) + ) + .safeParse(tag) + .success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder={t( + "otpEmailEnter" + )} + tags={ + form.getValues() + .emails ?? [] + } + setTags={(newEmails) => { + form.setValue( + "emails", + newEmails as [ + Tag, + ...Tag[] + ] + ); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t("otpEmailEnterDescription")} + + + )} + /> + )} + + + + + + + + + ); } diff --git a/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx b/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx index 19c5bb719..d4d9b2de2 100644 --- a/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx @@ -4,6 +4,7 @@ import { SettingsSection, SettingsSectionBody, SettingsSectionDescription, + SettingsSectionFooter, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle @@ -342,7 +343,7 @@ export function EditPolicyUsersRolesSectionForm({
-
+ -
+
From a1eb2484748788e3fa1c6b9a7c57141ab3a1c234 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 4 Mar 2026 01:10:48 +0100 Subject: [PATCH 46/89] =?UTF-8?q?=F0=9F=94=A8=20remove=20docker=20compose?= =?UTF-8?q?=20mail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.mail.yml | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 docker-compose.mail.yml diff --git a/docker-compose.mail.yml b/docker-compose.mail.yml deleted file mode 100644 index 49aaead9f..000000000 --- a/docker-compose.mail.yml +++ /dev/null @@ -1,13 +0,0 @@ -services: - mailer: - image: axllent/mailpit - ports: - - 8025:8025 - - 1025:1025 - volumes: - - mailpit-storage:/data - environment: - - MP_DATABASE=/data/mailpit.db - -volumes: - mailpit-storage: From 7f6ca3175734decb138f52685f6e12618ce96097 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 4 Mar 2026 01:46:56 +0100 Subject: [PATCH 47/89] =?UTF-8?q?=F0=9F=9A=A7=20Email=20whiteList=20for=20?= =?UTF-8?q?resource=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/auth/actions.ts | 3 +- server/routers/external.ts | 9 ++ server/routers/integration.ts | 9 ++ server/routers/policy/index.ts | 1 + .../policy/setResourcePolicyWhitelist.ts | 132 ++++++++++++++++++ 5 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 server/routers/policy/setResourcePolicyWhitelist.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index b3e6c5f75..5c512181a 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -144,7 +144,8 @@ export enum ActionsEnum { setResourcePolicyUsers = "setResourcePolicyUsers", setResourcePolicyPassword = "setResourcePolicyPassword", setResourcePolicyPincode = "setResourcePolicyPincode", - setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth" + setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth", + setResourcePolicyWhitelist = "setResourcePolicyWhitelist" } export async function checkUserActionPermission( diff --git a/server/routers/external.ts b/server/routers/external.ts index 379ff794c..faee76486 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -761,6 +761,15 @@ authenticated.put( policy.setResourcePolicyHeaderAuth ); +authenticated.put( + "/resource-policy/:resourcePolicyId/whitelist", + verifyResourcePolicyAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.setResourcePolicyWhitelist), + logActionAudit(ActionsEnum.setResourcePolicyWhitelist), + policy.setResourcePolicyWhitelist +); + authenticated.post( `/resource/:resourceId/password`, verifyResourceAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 52c839b18..89ec2c2d7 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -659,6 +659,15 @@ authenticated.put( policy.setResourcePolicyHeaderAuth ); +authenticated.put( + "/resource-policy/:resourcePolicyId/whitelist", + verifyApiKeyResourcePolicyAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyWhitelist), + logActionAudit(ActionsEnum.setResourcePolicyWhitelist), + policy.setResourcePolicyWhitelist +); + authenticated.post( "/resource/:resourceId/roles/add", verifyApiKeyResourceAccess, diff --git a/server/routers/policy/index.ts b/server/routers/policy/index.ts index 9d191af15..7719ffdfe 100644 --- a/server/routers/policy/index.ts +++ b/server/routers/policy/index.ts @@ -4,3 +4,4 @@ export * from "./setResourcePolicyAccessControl"; export * from "./setResourcePolicyPassword"; export * from "./setResourcePolicyPincode"; export * from "./setResourcePolicyHeaderAuth"; +export * from "./setResourcePolicyWhitelist"; diff --git a/server/routers/policy/setResourcePolicyWhitelist.ts b/server/routers/policy/setResourcePolicyWhitelist.ts new file mode 100644 index 000000000..63fabeaa0 --- /dev/null +++ b/server/routers/policy/setResourcePolicyWhitelist.ts @@ -0,0 +1,132 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resourcePolicies, resourcePolicyWhiteList } from "@server/db"; +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 { and, eq } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const setResourcePolicyWhitelistBodySchema = z.strictObject({ + emailWhitelistEnabled: z.boolean(), + emails: z + .array( + z.email().or( + z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, { + error: "Invalid email address. Wildcard (*) must be the entire local part." + }) + ) + ) + .max(50) + .transform((v) => v.map((e) => e.toLowerCase())) +}); + +const setResourcePolicyWhitelistParamsSchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}/whitelist", + description: + "Set email whitelist for a resource policy. This will replace all existing emails.", + tags: [OpenAPITags.Resource], + request: { + params: setResourcePolicyWhitelistParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyWhitelistBodySchema + } + } + } + }, + responses: {} +}); + +export async function setResourcePolicyWhitelist( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setResourcePolicyWhitelistBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const parsedParams = setResourcePolicyWhitelistParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourcePolicyId } = parsedParams.data; + const { emailWhitelistEnabled, emails } = parsedBody.data; + + const [policy] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)); + + if (!policy) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource policy not found") + ); + } + + await db.transaction(async (trx) => { + await trx + .update(resourcePolicies) + .set({ emailWhitelistEnabled }) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)); + + // delete all whitelist emails + await trx + .delete(resourcePolicyWhiteList) + .where( + eq( + resourcePolicyWhiteList.resourcePolicyId, + resourcePolicyId + ) + ); + + if (emailWhitelistEnabled && emails.length > 0) { + await trx.insert(resourcePolicyWhiteList).values( + emails.map((email) => ({ + email, + resourcePolicyId + })) + ); + } + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Whitelist set for resource policy successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} From e44b15ecd540b127dbab1324b49e800f35ab1319 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 4 Mar 2026 01:54:50 +0100 Subject: [PATCH 48/89] =?UTF-8?q?=E2=9C=A8set=20opt=20email=20whitelist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EditPolicyOtpEmailSectionForm.tsx | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx b/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx index 93cb2b295..842fc0564 100644 --- a/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx @@ -16,6 +16,10 @@ import z from "zod"; import { createPolicySchema, type PolicyFormValues } from "."; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import type { AxiosResponse } from "axios"; import { SwitchInput } from "@app/components/SwitchInput"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; @@ -52,6 +56,8 @@ export function EditPolicyOtpEmailSectionForm({ const { policy } = useResourcePolicyContext(); const router = useRouter(); + const api = createApiClient(useEnvContext()); + const form = useForm({ resolver: zodResolver( createPolicySchema.pick({ @@ -87,7 +93,40 @@ export function EditPolicyOtpEmailSectionForm({ const payload = form.getValues(); - console.log({ payload, policy }); + try { + const res = await api + .put>( + `/resource-policy/${policy.resourcePolicyId}/whitelist`, + { + emailWhitelistEnabled: payload.emailWhitelistEnabled, + emails: payload.emails?.map((e) => e.text) ?? [] + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }); + + if (res && res.status === 200) { + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: t("policyErrorUpdateMessageDescription") + }); + } } if (!isExpanded) { From cbce9fae3a2206d81f5c163b4a37db28d25964cf Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 4 Mar 2026 16:36:49 +0100 Subject: [PATCH 49/89] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resource-policy/EditPolicyOtpEmailSectionForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx b/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx index 842fc0564..c120c13da 100644 --- a/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx @@ -182,7 +182,7 @@ export function EditPolicyOtpEmailSectionForm({ { form.setValue("emailWhitelistEnabled", val); }} From f42c013f33359cd8a4da228a14c8c3467608e44c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 4 Mar 2026 17:41:55 +0100 Subject: [PATCH 50/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + server/routers/policy/getResourcePolicy.ts | 5 +- .../EditPolicyAuthMethodsSectionForm.tsx | 2 +- .../resource-policy/EditPolicyForm.tsx | 11 ++-- .../EditPolicyRulesSectionForm.tsx | 66 ++++--------------- 5 files changed, 25 insertions(+), 60 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 9fa9748cc..1ad1d89be 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -660,6 +660,7 @@ "policyErrorUpdateMessageDescription": "An unexpected error occurred", "policyCreatedSuccess": "Resource policy succesfully created", "policyUpdatedSuccess": "Resource policy succesfully updated", + "authMethodsSave": "Save auth methods", "resourceErrorCreate": "Error creating resource", "resourceErrorCreateDescription": "An error occurred when creating the resource", "resourceErrorCreateMessage": "Error creating resource:", diff --git a/server/routers/policy/getResourcePolicy.ts b/server/routers/policy/getResourcePolicy.ts index 124f67718..02c370199 100644 --- a/server/routers/policy/getResourcePolicy.ts +++ b/server/routers/policy/getResourcePolicy.ts @@ -118,7 +118,10 @@ async function query(params: z.infer) { .where(eq(rolePolicies.resourcePolicyId, res.resourcePolicyId)); const policyEmailWhiteList = await db - .select() + .select({ + whiteListId: resourcePolicyWhiteList.whitelistId, + email: resourcePolicyWhiteList.email + }) .from(resourcePolicyWhiteList) .where( eq(resourcePolicyWhiteList.resourcePolicyId, res.resourcePolicyId) diff --git a/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx b/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx index 8a7efce1d..57f37c958 100644 --- a/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx @@ -646,7 +646,7 @@ export function EditPolicyAuthMethodsSectionForm() { loading={isSubmitting} disabled={isSubmitting} > - {t("saveSettings")} + {t("authMethodsSave")}
diff --git a/src/components/resource-policy/EditPolicyForm.tsx b/src/components/resource-policy/EditPolicyForm.tsx index 4647c84f1..e913474a9 100644 --- a/src/components/resource-policy/EditPolicyForm.tsx +++ b/src/components/resource-policy/EditPolicyForm.tsx @@ -22,6 +22,7 @@ import { EditPolicyAuthMethodsSectionForm } from "./EditPolicyAuthMethodsSection import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm"; import { EditPolicyUsersRolesSectionForm } from "./EditPolicyUserRolesSectionForm"; import { EditPolicyOtpEmailSectionForm } from "./EditPolicyOtpEmailSectionForm"; +import { EditPolicyRulesSectionForm } from "./EditPolicyRulesSectionForm"; // ─── EditPolicyForm ───────────────────────────────────────────────────────── @@ -112,12 +113,10 @@ export function EditPolicyForm({ hidePolicyNameForm }: EditPolicyFormProps) { emailEnabled={env.email.emailEnabled} /> - {/* - */} + ); } diff --git a/src/components/resource-policy/EditPolicyRulesSectionForm.tsx b/src/components/resource-policy/EditPolicyRulesSectionForm.tsx index 692cbf463..f8f044740 100644 --- a/src/components/resource-policy/EditPolicyRulesSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyRulesSectionForm.tsx @@ -1,40 +1,22 @@ "use client"; import { - SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, - SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; - -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { orgQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; -import { build } from "@server/build"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { UserType } from "@server/types/UserTypes"; -import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import z from "zod"; -import { type PolicyFormValues, createPolicySchema } from "."; import { toast } from "@app/hooks/useToast"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { orgs, type ResourcePolicy } from "@server/db"; -import type { AxiosResponse } from "axios"; -import { useRouter } from "next/navigation"; +import { createPolicySchema, type PolicyFormValues } from "."; import { SwitchInput } from "@app/components/SwitchInput"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; import { Command, @@ -47,7 +29,6 @@ import { import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, @@ -76,21 +57,6 @@ import { TableHeader, TableRow } from "@app/components/ui/table"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot -} from "@app/components/ui/input-otp"; import { MAJOR_ASNS } from "@server/db/asns"; import { COUNTRIES } from "@server/db/countries"; @@ -108,21 +74,10 @@ import { getSortedRowModel, useReactTable } from "@tanstack/react-table"; -import { - ArrowUpDown, - Binary, - Bot, - Check, - ChevronsUpDown, - InfoIcon, - Key, - Plus -} from "lucide-react"; +import { ArrowUpDown, Check, ChevronsUpDown, Plus } from "lucide-react"; -import { useCallback, useMemo, useState, useActionState } from "react"; -import { UseFormReturn, useForm, useWatch } from "react-hook-form"; -import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; -import { cn } from "@app/lib/cn"; +import { useCallback, useMemo, useState } from "react"; +import { UseFormReturn, useForm } from "react-hook-form"; // ─── PolicyRulesSection ─────────────────────────────────────────────────────── @@ -145,17 +100,24 @@ type LocalRule = { }; type PolicyRulesSectionProps = { - form: UseFormReturn; isMaxmindAvailable: boolean; isMaxmindAsnAvailable: boolean; }; -export function PolicyRulesSection({ - form, +export function EditPolicyRulesSectionForm({ isMaxmindAvailable, isMaxmindAsnAvailable }: PolicyRulesSectionProps) { const t = useTranslations(); + + const form = useForm({ + resolver: zodResolver( + createPolicySchema.pick({ + rules: true, + applyRules: true + }) + ) + }); const [isExpanded, setIsExpanded] = useState(false); const [rules, setRules] = useState([]); const [rulesEnabled, setRulesEnabled] = useState(false); From 1a5e9f10053e94432abe7fb38ef539b25a128499 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 4 Mar 2026 19:31:59 +0100 Subject: [PATCH 51/89] =?UTF-8?q?=F0=9F=9A=A7=20resource=20policy=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + server/auth/actions.ts | 3 +- server/db/pg/schema/schema.ts | 1 + server/routers/external.ts | 9 + server/routers/integration.ts | 9 + server/routers/policy/index.ts | 1 + .../routers/policy/setResourcePolicyRules.ts | 162 ++++++++++++++++++ .../EditPolicyOtpEmailSectionForm.tsx | 2 +- .../EditPolicyRulesSectionForm.tsx | 14 +- 9 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 server/routers/policy/setResourcePolicyRules.ts diff --git a/messages/en-US.json b/messages/en-US.json index 1ad1d89be..af2eef9cb 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -661,6 +661,7 @@ "policyCreatedSuccess": "Resource policy succesfully created", "policyUpdatedSuccess": "Resource policy succesfully updated", "authMethodsSave": "Save auth methods", + "rulesSave": "Save Rules", "resourceErrorCreate": "Error creating resource", "resourceErrorCreateDescription": "An error occurred when creating the resource", "resourceErrorCreateMessage": "Error creating resource:", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 5c512181a..b34b3fe57 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -145,7 +145,8 @@ export enum ActionsEnum { setResourcePolicyPassword = "setResourcePolicyPassword", setResourcePolicyPincode = "setResourcePolicyPincode", setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth", - setResourcePolicyWhitelist = "setResourcePolicyWhitelist" + setResourcePolicyWhitelist = "setResourcePolicyWhitelist", + setResourcePolicyRules = "setResourcePolicyRules" } export async function checkUserActionPermission( diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index e4388b502..b33360b1f 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -678,6 +678,7 @@ export const policyRules = pgTable("policyRules", { export const resourcePolicies = pgTable("resourcePolicies", { resourcePolicyId: serial("resourcePolicyId").primaryKey(), sso: boolean("sso").notNull().default(true), + applyRules: boolean("applyRules").notNull().default(false), scope: varchar("scope") .$type<"global" | "resource">() .notNull() diff --git a/server/routers/external.ts b/server/routers/external.ts index 6e74e44a9..671bd4ac7 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -737,6 +737,15 @@ authenticated.put( policy.setResourcePolicyWhitelist ); +authenticated.put( + "/resource-policy/:resourcePolicyId/rules", + verifyResourcePolicyAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.setResourcePolicyRules), + logActionAudit(ActionsEnum.setResourcePolicyRules), + policy.setResourcePolicyRules +); + authenticated.post( `/resource/:resourceId/password`, verifyResourceAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 89ec2c2d7..92f1531ee 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -668,6 +668,15 @@ authenticated.put( policy.setResourcePolicyWhitelist ); +authenticated.put( + "/resource-policy/:resourcePolicyId/rules", + verifyApiKeyResourcePolicyAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRules), + logActionAudit(ActionsEnum.setResourcePolicyRules), + policy.setResourcePolicyRules +); + authenticated.post( "/resource/:resourceId/roles/add", verifyApiKeyResourceAccess, diff --git a/server/routers/policy/index.ts b/server/routers/policy/index.ts index 7719ffdfe..2ebe6da7e 100644 --- a/server/routers/policy/index.ts +++ b/server/routers/policy/index.ts @@ -5,3 +5,4 @@ export * from "./setResourcePolicyPassword"; export * from "./setResourcePolicyPincode"; export * from "./setResourcePolicyHeaderAuth"; export * from "./setResourcePolicyWhitelist"; +export * from "./setResourcePolicyRules"; diff --git a/server/routers/policy/setResourcePolicyRules.ts b/server/routers/policy/setResourcePolicyRules.ts new file mode 100644 index 000000000..147a67814 --- /dev/null +++ b/server/routers/policy/setResourcePolicyRules.ts @@ -0,0 +1,162 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, policyRules, resourcePolicies } 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 { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; +import { OpenAPITags, registry } from "@server/openApi"; + +const ruleSchema = z.strictObject({ + action: z.enum(["ACCEPT", "DROP", "PASS"]).openapi({ + type: "string", + enum: ["ACCEPT", "DROP", "PASS"], + description: "rule action" + }), + match: z.enum(["CIDR", "IP", "PATH"]).openapi({ + type: "string", + enum: ["CIDR", "IP", "PATH"], + description: "rule match" + }), + value: z.string().min(1), + priority: z.int(), + enabled: z.boolean().optional() +}); + +const setResourcePolicyRulesBodySchema = z.strictObject({ + applyRules: z.boolean(), + rules: z.array(ruleSchema) +}); + +const setResourcePolicyRulesParamsSchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}/rules", + description: + "Set all rules for a resource policy at once. This will replace all existing rules.", + tags: [OpenAPITags.Policy], + request: { + params: setResourcePolicyRulesParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyRulesBodySchema + } + } + } + }, + responses: {} +}); + +export async function setResourcePolicyRules( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = setResourcePolicyRulesParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = setResourcePolicyRulesBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { resourcePolicyId } = parsedParams.data; + const { applyRules, rules } = parsedBody.data; + + const [policy] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .limit(1); + + if (!policy) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource policy not found") + ); + } + + for (const rule of rules) { + if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid CIDR provided" + ) + ); + } else if (rule.match === "IP" && !isValidIP(rule.value)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided") + ); + } else if ( + rule.match === "PATH" && + !isValidUrlGlobPattern(rule.value) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid URL glob pattern provided" + ) + ); + } + } + + await db.transaction(async (trx) => { + await trx + .update(resourcePolicies) + .set({ applyRules }) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)); + + await trx + .delete(policyRules) + .where(eq(policyRules.resourcePolicyId, resourcePolicyId)); + + if (rules.length > 0) { + await trx.insert(policyRules).values( + rules.map((rule) => ({ + resourcePolicyId, + ...rule + })) + ); + } + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Resource policy rules set successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx b/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx index c120c13da..3a117bd8a 100644 --- a/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx @@ -68,7 +68,7 @@ export function EditPolicyOtpEmailSectionForm({ defaultValues: { emailWhitelistEnabled: policy.emailWhitelistEnabled, emails: policy.emailWhiteList.map((email) => ({ - id: email.whitelistId.toString(), + id: email.whiteListId.toString(), text: email.email })) } diff --git a/src/components/resource-policy/EditPolicyRulesSectionForm.tsx b/src/components/resource-policy/EditPolicyRulesSectionForm.tsx index f8f044740..23c8a1dd9 100644 --- a/src/components/resource-policy/EditPolicyRulesSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyRulesSectionForm.tsx @@ -4,6 +4,7 @@ import { SettingsSection, SettingsSectionBody, SettingsSectionDescription, + SettingsSectionFooter, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; @@ -76,7 +77,7 @@ import { } from "@tanstack/react-table"; import { ArrowUpDown, Check, ChevronsUpDown, Plus } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useState, useTransition } from "react"; import { UseFormReturn, useForm } from "react-hook-form"; // ─── PolicyRulesSection ─────────────────────────────────────────────────────── @@ -615,6 +616,8 @@ export function EditPolicyRulesSectionForm({ state: { pagination: { pageIndex: 0, pageSize: 1000 } } }); + const [isPending, startTransition] = useTransition(); + if (!isExpanded) { return ( @@ -1070,6 +1073,15 @@ export function EditPolicyRulesSectionForm({
+ + +
); } From 8a3c0d9a08d54d27725f3e09a50dbcdea2410e22 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 5 Mar 2026 17:51:55 +0100 Subject: [PATCH 52/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20add=20openapi=20sche?= =?UTF-8?q?ma=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/policy/getResourcePolicy.ts | 9 ++++++++- server/routers/policy/setResourcePolicyAccessControl.ts | 9 +++++++-- server/routers/policy/setResourcePolicyRules.ts | 5 ++++- server/routers/site/listSites.ts | 6 ++++-- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/server/routers/policy/getResourcePolicy.ts b/server/routers/policy/getResourcePolicy.ts index 02c370199..c5b567a16 100644 --- a/server/routers/policy/getResourcePolicy.ts +++ b/server/routers/policy/getResourcePolicy.ts @@ -28,7 +28,14 @@ const getResourcePolicySchema = z }) .or( z.strictObject({ - resourcePolicyId: z.coerce.number().int().positive() + resourcePolicyId: z.coerce + .number() + .int() + .positive() + .openapi({ + type: "integer", + description: "Resource policy ID" + }) }) ); diff --git a/server/routers/policy/setResourcePolicyAccessControl.ts b/server/routers/policy/setResourcePolicyAccessControl.ts index 1f91d8ccc..6c0e19b68 100644 --- a/server/routers/policy/setResourcePolicyAccessControl.ts +++ b/server/routers/policy/setResourcePolicyAccessControl.ts @@ -22,8 +22,13 @@ import { OpenAPITags, registry } from "@server/openApi"; const setResourcePolicyAcccessControlBodySchema = z.strictObject({ sso: z.boolean(), userIds: z.array(z.string()), - roleIds: z.array(z.int().positive()), - skipToIdpId: z.int().positive().optional().nullish() + roleIds: z.array(z.int().positive()).openapi({ + type: "array" + }), + skipToIdpId: z.int().positive().optional().nullable().openapi({ + type: "integer", + description: "Page number to retrieve" + }) }); const setResourcePolicyAccessControlParamsSchema = z.strictObject({ diff --git a/server/routers/policy/setResourcePolicyRules.ts b/server/routers/policy/setResourcePolicyRules.ts index 147a67814..61ed9bece 100644 --- a/server/routers/policy/setResourcePolicyRules.ts +++ b/server/routers/policy/setResourcePolicyRules.ts @@ -26,7 +26,10 @@ const ruleSchema = z.strictObject({ description: "rule match" }), value: z.string().min(1), - priority: z.int(), + priority: z.int().openapi({ + type: "integer", + description: "Rule priority" + }), enabled: z.boolean().optional() }); diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 9ff7a6933..dc6b62ba8 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -97,7 +97,7 @@ const listSitesSchema = z.object({ page: z.coerce .number() // for prettier formatting .int() - .min(0) + .positive() .optional() .catch(1) .default(1) @@ -278,7 +278,9 @@ export async function listSites( // we need to add `as` so that drizzle filters the result as a subquery const countQuery = db.$count( - querySitesBase().where(and(...conditions)).as("filtered_sites") + querySitesBase() + .where(and(...conditions)) + .as("filtered_sites") ); const siteListQuery = baseQuery From de2980e1bca95254e5d9c9b215cdd1667c59b219 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 5 Mar 2026 18:13:30 +0100 Subject: [PATCH 53/89] =?UTF-8?q?=E2=9C=A8=20apply=20rules=20on=20resource?= =?UTF-8?q?=20policies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 2 +- server/routers/policy/getResourcePolicy.ts | 17 +++- .../routers/policy/setResourcePolicyRules.ts | 10 ++- .../EditPolicyRulesSectionForm.tsx | 77 ++++++++++++++++--- 4 files changed, 90 insertions(+), 16 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index b33360b1f..934dbb6da 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -661,7 +661,7 @@ export const resourceRules = pgTable("resourceRules", { value: varchar("value").notNull() }); -export const policyRules = pgTable("policyRules", { +export const resourcePolicyRules = pgTable("resourcePolicyRules", { ruleId: serial("ruleId").primaryKey(), resourcePolicyId: integer("resourcePolicyId") .notNull() diff --git a/server/routers/policy/getResourcePolicy.ts b/server/routers/policy/getResourcePolicy.ts index c5b567a16..2a975dde7 100644 --- a/server/routers/policy/getResourcePolicy.ts +++ b/server/routers/policy/getResourcePolicy.ts @@ -1,6 +1,7 @@ import { db, idp, + resourcePolicyRules, resourcePolicies, resourcePolicyHeaderAuth, resourcePolicyPassword, @@ -56,6 +57,7 @@ async function query(params: z.infer) { .select({ resourcePolicyId: resourcePolicies.resourcePolicyId, sso: resourcePolicies.sso, + applyRules: resourcePolicies.applyRules, emailWhitelistEnabled: resourcePolicies.emailWhitelistEnabled, idpId: resourcePolicies.idpId, niceId: resourcePolicies.niceId, @@ -134,11 +136,24 @@ async function query(params: z.infer) { eq(resourcePolicyWhiteList.resourcePolicyId, res.resourcePolicyId) ); + const policyRules = await db + .select({ + ruleId: resourcePolicyRules.ruleId, + enabled: resourcePolicyRules.enabled, + priority: resourcePolicyRules.priority, + action: resourcePolicyRules.action, + match: resourcePolicyRules.match, + value: resourcePolicyRules.value + }) + .from(resourcePolicyRules) + .where(eq(resourcePolicyRules.resourcePolicyId, res.resourcePolicyId)); + return { ...res, roles: policyRoles, users: policyUsers, - emailWhiteList: policyEmailWhiteList + emailWhiteList: policyEmailWhiteList, + rules: policyRules }; } diff --git a/server/routers/policy/setResourcePolicyRules.ts b/server/routers/policy/setResourcePolicyRules.ts index 61ed9bece..533e01c0e 100644 --- a/server/routers/policy/setResourcePolicyRules.ts +++ b/server/routers/policy/setResourcePolicyRules.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, policyRules, resourcePolicies } from "@server/db"; +import { db, resourcePolicyRules, resourcePolicies } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -136,11 +136,13 @@ export async function setResourcePolicyRules( .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)); await trx - .delete(policyRules) - .where(eq(policyRules.resourcePolicyId, resourcePolicyId)); + .delete(resourcePolicyRules) + .where( + eq(resourcePolicyRules.resourcePolicyId, resourcePolicyId) + ); if (rules.length > 0) { - await trx.insert(policyRules).values( + await trx.insert(resourcePolicyRules).values( rules.map((rule) => ({ resourcePolicyId, ...rule diff --git a/src/components/resource-policy/EditPolicyRulesSectionForm.tsx b/src/components/resource-policy/EditPolicyRulesSectionForm.tsx index 23c8a1dd9..cd6f39fe1 100644 --- a/src/components/resource-policy/EditPolicyRulesSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyRulesSectionForm.tsx @@ -78,7 +78,12 @@ import { import { ArrowUpDown, Check, ChevronsUpDown, Plus } from "lucide-react"; import { useCallback, useMemo, useState, useTransition } from "react"; -import { UseFormReturn, useForm } from "react-hook-form"; +import { UseFormReturn, useForm, useWatch } from "react-hook-form"; +import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import type { AxiosResponse } from "axios"; +import { useRouter } from "next/navigation"; // ─── PolicyRulesSection ─────────────────────────────────────────────────────── @@ -111,17 +116,31 @@ export function EditPolicyRulesSectionForm({ }: PolicyRulesSectionProps) { const t = useTranslations(); + const { policy } = useResourcePolicyContext(); + const api = createApiClient(useEnvContext()); + const router = useRouter(); + const form = useForm({ resolver: zodResolver( createPolicySchema.pick({ rules: true, applyRules: true }) - ) + ), + defaultValues: { + applyRules: policy.applyRules, + rules: policy.rules + } }); - const [isExpanded, setIsExpanded] = useState(false); - const [rules, setRules] = useState([]); - const [rulesEnabled, setRulesEnabled] = useState(false); + + const rulesEnabled = useWatch({ + control: form.control, + name: "applyRules" + }); + + const [rules, setRules] = useState(policy.rules); + const [isExpanded, setIsExpanded] = useState(rulesEnabled); + const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = useState(false); const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); @@ -618,6 +637,45 @@ export function EditPolicyRulesSectionForm({ const [isPending, startTransition] = useTransition(); + async function saveRules() { + const isValid = form.trigger(); + if (!isValid) return; + + const payload = form.getValues(); + console.log({ payload }); + + try { + const res = await api + .put< + AxiosResponse<{}> + >(`/resource-policy/${policy.resourcePolicyId}/rules`, payload) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }); + + if (res && res.status === 200) { + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: t("policyErrorUpdateMessageDescription") + }); + } + } + if (!isExpanded) { return ( @@ -659,9 +717,8 @@ export function EditPolicyRulesSectionForm({ { - setRulesEnabled(val); form.setValue("applyRules", val); }} /> @@ -1075,9 +1132,9 @@ export function EditPolicyRulesSectionForm({ From 51eb782831a6f1432e2b78d56ffcf6a71004b931 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 5 Mar 2026 18:14:46 +0100 Subject: [PATCH 54/89] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/resource-policy/EditPolicyForm.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/resource-policy/EditPolicyForm.tsx b/src/components/resource-policy/EditPolicyForm.tsx index e913474a9..076e726dd 100644 --- a/src/components/resource-policy/EditPolicyForm.tsx +++ b/src/components/resource-policy/EditPolicyForm.tsx @@ -100,13 +100,14 @@ export function EditPolicyForm({ hidePolicyNameForm }: EditPolicyFormProps) { return ( - {/* Name */} {!hidePolicyNameForm && } + + Date: Thu, 5 Mar 2026 18:24:04 +0100 Subject: [PATCH 55/89] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20create=20resource?= =?UTF-8?q?=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routers/policy/createResourcePolicy.ts | 63 ++++++++++++++++++- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts index 29bccd48b..aa9500d1c 100644 --- a/server/private/routers/policy/createResourcePolicy.ts +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -26,15 +26,72 @@ const createResourcePolicyParamsSchema = z.strictObject({ orgId: z.string() }); +const ruleSchema = z.strictObject({ + action: z.enum(["ACCEPT", "DROP", "PASS"]).openapi({ + type: "string", + enum: ["ACCEPT", "DROP", "PASS"], + description: "rule action" + }), + match: z.enum(["CIDR", "IP", "PATH"]).openapi({ + type: "string", + enum: ["CIDR", "IP", "PATH"], + description: "rule match" + }), + value: z.string().min(1), + priority: z.int().openapi({ + type: "integer", + description: "Rule priority" + }), + enabled: z.boolean().optional() +}); + const createResourcePolicyBodySchema = z.strictObject({ name: z.string().min(1).max(255), - sso: z.boolean(), - skipToIdpId: z.int().positive().optional(), + // Access control + sso: z.boolean().default(true), + skipToIdpId: z + .int() + .positive() + .optional() + .nullable() + .openapi({ type: "integer" }), roleIds: z .array(z.string().transform(Number).pipe(z.int().positive())) .optional() .default([]), - userIds: z.array(z.string()).optional().default([]) + userIds: z.array(z.string()).optional().default([]), + // auth methods + password: z.string().min(4).max(100).nullable().optional(), + pincode: z + .string() + .regex(/^\d{6}$/) + .or(z.null()) + .optional(), + headerAuth: z + .object({ + user: z.string().min(4).max(100), + password: z.string().min(4).max(100), + extendedCompatibility: z.boolean() + }) + .nullable() + .optional(), + // email OTP + emailWhitelistEnabled: z.boolean().optional().default(false), + emails: z + .array( + z.email().or( + z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, { + error: "Invalid email address. Wildcard (*) must be the entire local part." + }) + ) + ) + .max(50) + .transform((v) => v.map((e) => e.toLowerCase())) + .optional() + .default([]), + // rules + applyRules: z.boolean().default(false), + rules: z.array(ruleSchema).optional().default([]) }); registry.registerPath({ From 595842c2c9cc5fee3bf4251e1779dab88d04e0e2 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 5 Mar 2026 18:48:33 +0100 Subject: [PATCH 56/89] =?UTF-8?q?=E2=9C=A8=20finish=20create=20policy=20en?= =?UTF-8?q?dpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routers/policy/createResourcePolicy.ts | 106 +++++++++++++++++- 1 file changed, 104 insertions(+), 2 deletions(-) diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts index aa9500d1c..8e2757270 100644 --- a/server/private/routers/policy/createResourcePolicy.ts +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -10,6 +10,11 @@ import { idpOrg, orgs, resourcePolicies, + resourcePolicyHeaderAuth, + resourcePolicyPassword, + resourcePolicyPincode, + resourcePolicyRules, + resourcePolicyWhiteList, rolePolicies, roles, userOrgs, @@ -21,6 +26,12 @@ import { and, eq, inArray, not, type InferInsertModel } from "drizzle-orm"; import logger from "@server/logger"; import { getUniqueResourcePolicyName } from "@server/db/names"; import response from "@server/lib/response"; +import { hashPassword } from "@server/auth/password"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; const createResourcePolicyParamsSchema = z.strictObject({ orgId: z.string() @@ -164,7 +175,20 @@ export async function createResourcePolicy( ); } - const { name, sso, userIds, roleIds, skipToIdpId } = parsedBody.data; + const { + name, + sso, + userIds, + roleIds, + skipToIdpId, + applyRules, + emailWhitelistEnabled, + password, + pincode, + headerAuth, + emails, + rules + } = parsedBody.data; // Check if Identity provider in `skipToIdpId` exists if (skipToIdpId) { @@ -223,6 +247,31 @@ export async function createResourcePolicy( const niceId = await getUniqueResourcePolicyName(orgId); + for (const rule of rules) { + if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid CIDR provided" + ) + ); + } else if (rule.match === "IP" && !isValidIP(rule.value)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided") + ); + } else if ( + rule.match === "PATH" && + !isValidUrlGlobPattern(rule.value) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid URL glob pattern provided" + ) + ); + } + } + const policy = await db.transaction(async (trx) => { const [newPolicy] = await trx .insert(resourcePolicies) @@ -231,7 +280,9 @@ export async function createResourcePolicy( orgId, name, sso, - idpId: skipToIdpId + idpId: skipToIdpId, + applyRules, + emailWhitelistEnabled }) .returning(); @@ -272,6 +323,57 @@ export async function createResourcePolicy( await trx.insert(userPolicies).values(usersToAdd); } + if (password) { + const passwordHash = await hashPassword(password); + + await trx.insert(resourcePolicyPassword).values({ + resourcePolicyId: newPolicy.resourcePolicyId, + passwordHash + }); + } + + if (pincode) { + const pincodeHash = await hashPassword(pincode); + + await trx.insert(resourcePolicyPincode).values({ + resourcePolicyId: newPolicy.resourcePolicyId, + pincodeHash, + digitLength: 6 + }); + } + + if (headerAuth) { + const headerAuthHash = await hashPassword( + Buffer.from( + `${headerAuth.user}:${headerAuth.password}` + ).toString("base64") + ); + + await trx.insert(resourcePolicyHeaderAuth).values({ + resourcePolicyId: newPolicy.resourcePolicyId, + headerAuthHash, + extendedCompatibility: headerAuth.extendedCompatibility + }); + } + + if (emailWhitelistEnabled && emails.length > 0) { + await trx.insert(resourcePolicyWhiteList).values( + emails.map((email) => ({ + email, + resourcePolicyId: newPolicy.resourcePolicyId + })) + ); + } + + if (rules.length > 0) { + await trx.insert(resourcePolicyRules).values( + rules.map((rule) => ({ + resourcePolicyId: newPolicy.resourcePolicyId, + ...rule + })) + ); + } + return newPolicy; }); From cd5a38b1eb582884ace2bc014574f8a9c9c353a1 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 5 Mar 2026 18:56:35 +0100 Subject: [PATCH 57/89] =?UTF-8?q?=F0=9F=9A=A7=20WIP:=20create=20policy=20f?= =?UTF-8?q?orm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routers/policy/createResourcePolicy.ts | 18 +- .../CreatePolicyAuthMethodsSectionForm.tsx | 487 +++++ .../resource-policy/CreatePolicyForm.tsx | 1866 +---------------- .../CreatePolicyOtpEmailSectionForm.tsx | 177 ++ .../CreatePolicyRulesSectionForm.tsx | 1073 ++++++++++ .../CreatePolicyUserRolesSectionForm.tsx | 224 ++ 6 files changed, 1993 insertions(+), 1852 deletions(-) create mode 100644 src/components/resource-policy/CreatePolicyAuthMethodsSectionForm.tsx create mode 100644 src/components/resource-policy/CreatePolicyOtpEmailSectionForm.tsx create mode 100644 src/components/resource-policy/CreatePolicyRulesSectionForm.tsx create mode 100644 src/components/resource-policy/CreatePolicyUserRolesSectionForm.tsx diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts index 8e2757270..1bbdfe153 100644 --- a/server/private/routers/policy/createResourcePolicy.ts +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -1,9 +1,4 @@ -import { Request, Response, NextFunction } from "express"; -import z from "zod"; -import { OpenAPITags, registry } from "@server/openApi"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import { fromError } from "zod-validation-error"; +import { hashPassword } from "@server/auth/password"; import { db, idp, @@ -22,16 +17,21 @@ import { users, type ResourcePolicy } from "@server/db"; -import { and, eq, inArray, not, type InferInsertModel } from "drizzle-orm"; -import logger from "@server/logger"; import { getUniqueResourcePolicyName } from "@server/db/names"; import response from "@server/lib/response"; -import { hashPassword } from "@server/auth/password"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "@server/lib/validators"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq, inArray, type InferInsertModel } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import z from "zod"; +import { fromError } from "zod-validation-error"; const createResourcePolicyParamsSchema = z.strictObject({ orgId: z.string() diff --git a/src/components/resource-policy/CreatePolicyAuthMethodsSectionForm.tsx b/src/components/resource-policy/CreatePolicyAuthMethodsSectionForm.tsx new file mode 100644 index 000000000..fb456e823 --- /dev/null +++ b/src/components/resource-policy/CreatePolicyAuthMethodsSectionForm.tsx @@ -0,0 +1,487 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; + +import z from "zod"; + +import { type PolicyFormValues } from "."; + +import { SwitchInput } from "@app/components/SwitchInput"; +import { Button } from "@app/components/ui/button"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot +} from "@app/components/ui/input-otp"; + +import { Binary, Bot, Key, Plus } from "lucide-react"; + +import { useState } from "react"; +import { type UseFormReturn, useForm } from "react-hook-form"; + +// ─── CreatePolicyAuthMethodsSectionForm ─────────────────────────────────────── + +const setPasswordSchema = z.object({ + password: z.string().min(4).max(100) +}); + +const setPincodeSchema = z.object({ + pincode: z.string().length(6) +}); + +const setHeaderAuthSchema = z.object({ + user: z.string().min(4).max(100), + password: z.string().min(4).max(100), + extendedCompatibility: z.boolean() +}); + +export type CreatePolicyAuthMethodsSectionFormProps = { + form: UseFormReturn; +}; + +export function CreatePolicyAuthMethodsSectionForm({ + form +}: CreatePolicyAuthMethodsSectionFormProps) { + const t = useTranslations(); + const [isOpen, setIsOpen] = useState(false); + const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); + const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); + const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); + + const password = form.watch("password"); + const pincode = form.watch("pincode"); + const headerAuth = form.watch("headerAuth"); + + const passwordForm = useForm({ + resolver: zodResolver(setPasswordSchema), + defaultValues: { password: "" } + }); + + const pincodeForm = useForm({ + resolver: zodResolver(setPincodeSchema), + defaultValues: { pincode: "" } + }); + + const headerAuthForm = useForm({ + resolver: zodResolver(setHeaderAuthSchema), + defaultValues: { user: "", password: "", extendedCompatibility: true } + }); + + if (!isOpen) { + return ( + + + + {t("resourceAuthMethods")} + + + {t("resourcePolicyAuthMethodsDescription")} + + + + + + + ); + } + + return ( + <> + {/* Password Credenza */} + { + setIsSetPasswordOpen(val); + if (!val) passwordForm.reset(); + }} + > + + + + {t("resourcePasswordSetupTitle")} + + + {t("resourcePasswordSetupTitleDescription")} + + + +
+ { + form.setValue("password", data); + setIsSetPasswordOpen(false); + passwordForm.reset(); + })} + className="space-y-4" + id="set-password-form" + > + ( + + + {t("password")} + + + + + + + )} + /> + + +
+ + + + + + +
+
+ + {/* Pincode Credenza */} + { + setIsSetPincodeOpen(val); + if (!val) pincodeForm.reset(); + }} + > + + + + {t("resourcePincodeSetupTitle")} + + + {t("resourcePincodeSetupTitleDescription")} + + + +
+ { + form.setValue("pincode", data); + setIsSetPincodeOpen(false); + pincodeForm.reset(); + })} + className="space-y-4" + id="set-pincode-form" + > + ( + + + {t("resourcePincode")} + + +
+ + + + + + + + + + +
+
+ +
+ )} + /> + + +
+ + + + + + +
+
+ + {/* Header Auth Credenza */} + { + setIsSetHeaderAuthOpen(val); + if (!val) headerAuthForm.reset(); + }} + > + + + + {t("resourceHeaderAuthSetupTitle")} + + + {t("resourceHeaderAuthSetupTitleDescription")} + + + +
+ { + form.setValue("headerAuth", data); + setIsSetHeaderAuthOpen(false); + headerAuthForm.reset(); + } + )} + className="space-y-4" + id="set-header-auth-form" + > + ( + + {t("user")} + + + + + + )} + /> + ( + + + {t("password")} + + + + + + + )} + /> + ( + + + + + + + )} + /> + + +
+ + + + + + +
+
+ + + + + {t("resourceAuthMethods")} + + + {t("resourcePolicyAuthMethodsDescription")} + + + + + {/* Password row */} +
+
+ + + {t("resourcePasswordProtection", { + status: password + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Pincode row */} +
+
+ + + {t("resourcePincodeProtection", { + status: pincode + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Header auth row */} +
+
+ + + {headerAuth + ? t( + "resourceHeaderAuthProtectionEnabled" + ) + : t( + "resourceHeaderAuthProtectionDisabled" + )} + +
+ +
+
+
+
+ + ); +} diff --git a/src/components/resource-policy/CreatePolicyForm.tsx b/src/components/resource-policy/CreatePolicyForm.tsx index d805e8772..9cc2d06b4 100644 --- a/src/components/resource-policy/CreatePolicyForm.tsx +++ b/src/components/resource-policy/CreatePolicyForm.tsx @@ -32,95 +32,23 @@ import { orgs, type ResourcePolicy } from "@server/db"; import type { AxiosResponse } from "axios"; import { useRouter } from "next/navigation"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; -import { InfoPopup } from "@app/components/ui/info-popup"; import { Input } from "@app/components/ui/input"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { Switch } from "@app/components/ui/switch"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow -} from "@app/components/ui/table"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot -} from "@app/components/ui/input-otp"; -import { MAJOR_ASNS } from "@server/db/asns"; -import { COUNTRIES } from "@server/db/countries"; -import { - isValidCIDR, - isValidIP, - isValidUrlGlobPattern -} from "@server/lib/validators"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable -} from "@tanstack/react-table"; -import { - ArrowUpDown, - Binary, - Bot, - Check, - ChevronsUpDown, - InfoIcon, - Key, - Plus -} from "lucide-react"; - -import { useCallback, useMemo, useState, useActionState } from "react"; -import { UseFormReturn, useForm, useWatch } from "react-hook-form"; +import { useMemo, useActionState } from "react"; +import { useForm } from "react-hook-form"; +import { CreatePolicyUsersRolesSectionForm } from "./CreatePolicyUserRolesSectionForm"; +import { CreatePolicyAuthMethodsSectionForm } from "./CreatePolicyAuthMethodsSectionForm"; +import { CreatePolicyOtpEmailSectionForm } from "./CreatePolicyOtpEmailSectionForm"; +import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm"; // ─── CreatePolicyForm ───────────────────────────────────────────────────────── @@ -187,9 +115,21 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { `/org/${org.org.orgId}/resource-policy/`, { name: payload.name, + // access control sso: payload.sso, roleIds: payload.roles.map((r) => r.id), - userIds: payload.users.map((u) => u.id) + userIds: payload.users.map((u) => u.id), + skipToIdpId: payload.skipToIdpId, + // auth methods + password: payload.password?.password, + pincode: payload.pincode?.pincode, + headerAuth: payload.headerAuth, + // email OTP + emailWhitelistEnabled: payload.emailWhitelistEnabled, + emails: payload.emails.map((email) => email.text), + // rules + applyRules: payload.applyRules, + rules: payload.rules } ) .catch((e) => { @@ -298,18 +238,18 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
- - - + - ); } - -// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── - -type PolicyUsersRolesSectionProps = { - form: UseFormReturn; - allRoles: { id: string; text: string }[]; - allUsers: { id: string; text: string }[]; - allIdps: { id: number; text: string }[]; -}; - -export function PolicyUsersRolesSection({ - form, - allRoles, - allUsers, - allIdps -}: PolicyUsersRolesSectionProps) { - const t = useTranslations(); - const ssoEnabled = useWatch({ control: form.control, name: "sso" }); - const selectedIdpId = useWatch({ - control: form.control, - name: "skipToIdpId" - }); - const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< - number | null - >(null); - const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< - number | null - >(null); - - return ( - - - - {t("resourceUsersRoles")} - - - {t("resourcePolicyUsersRolesDescription")} - - - - - { - console.log(`form.setValue("sso", ${val})`); - form.setValue("sso", val); - }} - /> - - {ssoEnabled && ( - <> - ( - - {t("roles")} - - { - form.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={true} - autocompleteOptions={allRoles} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - {t("resourceRoleDescription")} - - - )} - /> - ( - - {t("users")} - - { - form.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={true} - autocompleteOptions={allUsers} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - - )} - - {ssoEnabled && allIdps.length > 0 && ( -
- - -

- {t("defaultIdentityProviderDescription")} -

-
- )} -
-
-
- ); -} - -// ─── PolicyAuthMethodsSection ───────────────────────────────────────────────── - -const setPasswordSchema = z.object({ - password: z.string().min(4).max(100) -}); - -const setPincodeSchema = z.object({ - pincode: z.string().length(6) -}); - -const setHeaderAuthSchema = z.object({ - user: z.string().min(4).max(100), - password: z.string().min(4).max(100), - extendedCompatibility: z.boolean() -}); - -type PolicyAuthMethodsSectionProps = { - form: UseFormReturn; -}; - -export function PolicyAuthMethodsSection({ - form -}: PolicyAuthMethodsSectionProps) { - const t = useTranslations(); - const [isOpen, setIsOpen] = useState(false); - const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); - const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); - const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); - - const password = form.watch("password"); - const pincode = form.watch("pincode"); - const headerAuth = form.watch("headerAuth"); - - const passwordForm = useForm({ - resolver: zodResolver(setPasswordSchema), - defaultValues: { password: "" } - }); - - const pincodeForm = useForm({ - resolver: zodResolver(setPincodeSchema), - defaultValues: { pincode: "" } - }); - - const headerAuthForm = useForm({ - resolver: zodResolver(setHeaderAuthSchema), - defaultValues: { user: "", password: "", extendedCompatibility: true } - }); - - if (!isOpen) { - return ( - - - - {t("resourceAuthMethods")} - - - {t("resourcePolicyAuthMethodsDescription")} - - - - - - - ); - } - - return ( - <> - {/* Password Credenza */} - { - setIsSetPasswordOpen(val); - if (!val) passwordForm.reset(); - }} - > - - - - {t("resourcePasswordSetupTitle")} - - - {t("resourcePasswordSetupTitleDescription")} - - - -
- { - form.setValue("password", data); - setIsSetPasswordOpen(false); - passwordForm.reset(); - })} - className="space-y-4" - id="set-password-form" - > - ( - - - {t("password")} - - - - - - - )} - /> - - -
- - - - - - -
-
- - {/* Pincode Credenza */} - { - setIsSetPincodeOpen(val); - if (!val) pincodeForm.reset(); - }} - > - - - - {t("resourcePincodeSetupTitle")} - - - {t("resourcePincodeSetupTitleDescription")} - - - -
- { - form.setValue("pincode", data); - setIsSetPincodeOpen(false); - pincodeForm.reset(); - })} - className="space-y-4" - id="set-pincode-form" - > - ( - - - {t("resourcePincode")} - - -
- - - - - - - - - - -
-
- -
- )} - /> - - -
- - - - - - -
-
- - {/* Header Auth Credenza */} - { - setIsSetHeaderAuthOpen(val); - if (!val) headerAuthForm.reset(); - }} - > - - - - {t("resourceHeaderAuthSetupTitle")} - - - {t("resourceHeaderAuthSetupTitleDescription")} - - - -
- { - form.setValue("headerAuth", data); - setIsSetHeaderAuthOpen(false); - headerAuthForm.reset(); - } - )} - className="space-y-4" - id="set-header-auth-form" - > - ( - - {t("user")} - - - - - - )} - /> - ( - - - {t("password")} - - - - - - - )} - /> - ( - - - - - - - )} - /> - - -
- - - - - - -
-
- - - - - {t("resourceAuthMethods")} - - - {t("resourcePolicyAuthMethodsDescription")} - - - - - {/* Password row */} -
-
- - - {t("resourcePasswordProtection", { - status: password - ? t("enabled") - : t("disabled") - })} - -
- -
- - {/* Pincode row */} -
-
- - - {t("resourcePincodeProtection", { - status: pincode - ? t("enabled") - : t("disabled") - })} - -
- -
- - {/* Header auth row */} -
-
- - - {headerAuth - ? t( - "resourceHeaderAuthProtectionEnabled" - ) - : t( - "resourceHeaderAuthProtectionDisabled" - )} - -
- -
-
-
-
- - ); -} - -// ─── PolicyOtpEmailSection ──────────────────────────────────────────────────── - -type PolicyOtpEmailSectionProps = { - form: UseFormReturn; - emailEnabled: boolean; -}; - -export function PolicyOtpEmailSection({ - form, - emailEnabled -}: PolicyOtpEmailSectionProps) { - const t = useTranslations(); - const [isOpen, setIsOpen] = useState(false); - const [whitelistEnabled, setWhitelistEnabled] = useState(false); - const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< - number | null - >(null); - - if (!isOpen) { - return ( - - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - - - - ); - } - - return ( - - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - - {!emailEnabled && ( - - - - {t("otpEmailSmtpRequired")} - - - {t("otpEmailSmtpRequiredDescription")} - - - )} - { - setWhitelistEnabled(val); - form.setValue("emailWhitelistEnabled", val); - }} - disabled={!emailEnabled} - /> - - {whitelistEnabled && emailEnabled && ( - ( - - - - - - {/* @ts-ignore */} - { - return z - .email() - .or( - z - .string() - .regex( - /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, - { - message: t( - "otpEmailErrorInvalid" - ) - } - ) - ) - .safeParse(tag).success; - }} - setActiveTagIndex={ - setActiveEmailTagIndex - } - placeholder={t("otpEmailEnter")} - tags={form.getValues().emails} - setTags={(newEmails) => { - form.setValue( - "emails", - newEmails as [Tag, ...Tag[]] - ); - }} - allowDuplicates={false} - sortTags={true} - /> - - - {t("otpEmailEnterDescription")} - - - )} - /> - )} - - - - ); -} - -// ─── PolicyRulesSection ─────────────────────────────────────────────────────── - -const addRuleSchema = z.object({ - action: z.enum(["ACCEPT", "DROP", "PASS"]), - match: z.string(), - value: z.string(), - priority: z.coerce.number().int().optional() -}); - -type LocalRule = { - ruleId: number; - action: "ACCEPT" | "DROP" | "PASS"; - match: string; - value: string; - priority: number; - enabled: boolean; - new?: boolean; - updated?: boolean; -}; - -type PolicyRulesSectionProps = { - form: UseFormReturn; - isMaxmindAvailable: boolean; - isMaxmindAsnAvailable: boolean; -}; - -export function PolicyRulesSection({ - form, - isMaxmindAvailable, - isMaxmindAsnAvailable -}: PolicyRulesSectionProps) { - const t = useTranslations(); - const [isOpen, setIsOpen] = useState(false); - const [rules, setRules] = useState([]); - const [rulesEnabled, setRulesEnabled] = useState(false); - const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = - useState(false); - const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); - - const addRuleForm = useForm({ - resolver: zodResolver(addRuleSchema), - defaultValues: { - action: "ACCEPT" as const, - match: "IP", - value: "" - } - }); - - const RuleAction = useMemo( - () => ({ - ACCEPT: t("alwaysAllow"), - DROP: t("alwaysDeny"), - PASS: t("passToAuth") - }), - [t] - ); - - const RuleMatch = useMemo( - () => ({ - PATH: t("path"), - IP: "IP", - CIDR: t("ipAddressRange"), - COUNTRY: t("country"), - ASN: "ASN" - }), - [t] - ); - - const syncFormRules = useCallback( - (updatedRules: LocalRule[]) => { - form.setValue( - "rules", - updatedRules.map( - ({ action, match, value, priority, enabled }) => ({ - action, - match, - value, - priority, - enabled - }) - ) - ); - }, - [form] - ); - - const addRule = useCallback( - function addRule(data: z.infer) { - const isDuplicate = rules.some( - (rule) => - rule.action === data.action && - rule.match === data.match && - rule.value === data.value - ); - if (isDuplicate) { - toast({ - variant: "destructive", - title: t("rulesErrorDuplicate"), - description: t("rulesErrorDuplicateDescription") - }); - return; - } - if (data.match === "CIDR" && !isValidCIDR(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidIpAddressRange"), - description: t("rulesErrorInvalidIpAddressRangeDescription") - }); - return; - } - if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidUrl"), - description: t("rulesErrorInvalidUrlDescription") - }); - return; - } - if (data.match === "IP" && !isValidIP(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidIpAddress"), - description: t("rulesErrorInvalidIpAddressDescription") - }); - return; - } - if ( - data.match === "COUNTRY" && - !COUNTRIES.some((c) => c.code === data.value) - ) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidCountry"), - description: t("rulesErrorInvalidCountryDescription") || "" - }); - return; - } - - let priority = data.priority; - if (priority === undefined) { - priority = - rules.reduce( - (acc, rule) => - rule.priority > acc ? rule.priority : acc, - 0 - ) + 1; - } - - const updatedRules = [ - ...rules, - { - ...data, - ruleId: new Date().getTime(), - new: true, - priority, - enabled: true - } - ]; - setRules(updatedRules); - syncFormRules(updatedRules); - addRuleForm.reset(); - }, - [rules, t, addRuleForm, syncFormRules] - ); - - const removeRule = useCallback( - function removeRule(ruleId: number) { - const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); - setRules(updatedRules); - syncFormRules(updatedRules); - }, - [rules, syncFormRules] - ); - - const updateRule = useCallback( - function updateRule(ruleId: number, data: Partial) { - const updatedRules = rules.map((rule) => - rule.ruleId === ruleId - ? { ...rule, ...data, updated: true } - : rule - ); - setRules(updatedRules); - syncFormRules(updatedRules); - }, - [rules, syncFormRules] - ); - - const getValueHelpText = useCallback( - function getValueHelpText(type: string) { - switch (type) { - case "CIDR": - return t("rulesMatchIpAddressRangeDescription"); - case "IP": - return t("rulesMatchIpAddress"); - case "PATH": - return t("rulesMatchUrl"); - case "COUNTRY": - return t("rulesMatchCountry"); - case "ASN": - return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; - } - }, - [t] - ); - - const columns: ColumnDef[] = useMemo( - () => [ - { - accessorKey: "priority", - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - e.currentTarget.focus()} - onBlur={(e) => { - const parsed = z.coerce - .number() - .int() - .optional() - .safeParse(e.target.value); - if (!parsed.success) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidPriority"), - description: t( - "rulesErrorInvalidPriorityDescription" - ) - }); - return; - } - updateRule(row.original.ruleId, { - priority: parsed.data - }); - }} - /> - ) - }, - { - accessorKey: "action", - header: () => {t("rulesAction")}, - cell: ({ row }) => ( - - ) - }, - { - accessorKey: "match", - header: () => ( - {t("rulesMatchType")} - ), - cell: ({ row }) => ( - - ) - }, - { - accessorKey: "value", - header: () => {t("value")}, - cell: ({ row }) => - row.original.match === "COUNTRY" ? ( - - - - - - - - - - {t("noCountryFound")} - - - {COUNTRIES.map((country) => ( - - updateRule( - row.original.ruleId, - { - value: country.code - } - ) - } - > - - {country.name} ( - {country.code}) - - ))} - - - - - - ) : row.original.match === "ASN" ? ( - - - - - - - - - - No ASN found. Enter a custom ASN - below. - - - {MAJOR_ASNS.map((asn) => ( - - updateRule( - row.original.ruleId, - { value: asn.code } - ) - } - > - - {asn.name} ({asn.code}) - - ))} - - - -
- - asn.code === - row.original.value - ) - ? row.original.value - : "" - } - onKeyDown={(e) => { - if (e.key === "Enter") { - const value = - e.currentTarget.value - .toUpperCase() - .replace(/^AS/, ""); - if (/^\d+$/.test(value)) { - updateRule( - row.original.ruleId, - { value: "AS" + value } - ); - } - } - }} - className="text-sm" - /> -
-
-
- ) : ( - - updateRule(row.original.ruleId, { - value: e.target.value - }) - } - /> - ) - }, - { - accessorKey: "enabled", - header: () => {t("enabled")}, - cell: ({ row }) => ( - - updateRule(row.original.ruleId, { enabled: val }) - } - /> - ) - }, - { - id: "actions", - header: () => {t("actions")}, - cell: ({ row }) => ( -
- -
- ) - } - ], - [ - t, - RuleAction, - RuleMatch, - isMaxmindAvailable, - isMaxmindAsnAvailable, - updateRule, - removeRule - ] - ); - - const table = useReactTable({ - data: rules, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - state: { pagination: { pageIndex: 0, pageSize: 1000 } } - }); - - if (!isOpen) { - return ( - - - - {t("rulesResource")} - - - {t("rulesResourcePolicyDescription")} - - - - - - - ); - } - - return ( - - - - {t("rulesResource")} - - - {t("rulesResourceDescription")} - - - -
-
- { - setRulesEnabled(val); - form.setValue("applyRules", val); - }} - /> -
- -
- -
- ( - - - {t("rulesAction")} - - - - - - - )} - /> - ( - - - {t("rulesMatchType")} - - - - - - - )} - /> - ( - - - - {addRuleForm.watch("match") === - "COUNTRY" ? ( - - - - - - - - - - {t( - "noCountryFound" - )} - - - {COUNTRIES.map( - ( - country - ) => ( - { - field.onChange( - country.code - ); - setOpenAddRuleCountrySelect( - false - ); - }} - > - - { - country.name - }{" "} - ( - { - country.code - } - - ) - - ) - )} - - - - - - ) : addRuleForm.watch( - "match" - ) === "ASN" ? ( - - - - - - - - - - No ASN - found. - Use the - custom - input - below. - - - {MAJOR_ASNS.map( - ( - asn - ) => ( - { - field.onChange( - asn.code - ); - setOpenAddRuleAsnSelect( - false - ); - }} - > - - { - asn.name - }{" "} - ( - { - asn.code - } - - ) - - ) - )} - - - -
- { - if ( - e.key === - "Enter" - ) { - const value = - e.currentTarget.value - .toUpperCase() - .replace( - /^AS/, - "" - ); - if ( - /^\d+$/.test( - value - ) - ) { - field.onChange( - "AS" + - value - ); - setOpenAddRuleAsnSelect( - false - ); - } - } - }} - className="text-sm" - /> -
-
-
- ) : ( - - )} -
- -
- )} - /> - -
-
- - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const isActionsColumn = - header.column.id === "actions"; - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column - .columnDef.header, - header.getContext() - )} - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => { - const isActionsColumn = - cell.column.id === "actions"; - return ( - - {flexRender( - cell.column.columnDef - .cell, - cell.getContext() - )} - - ); - })} - - )) - ) : ( - - - {t("rulesNoOne")} - - - )} - -
-
-
-
- ); -} diff --git a/src/components/resource-policy/CreatePolicyOtpEmailSectionForm.tsx b/src/components/resource-policy/CreatePolicyOtpEmailSectionForm.tsx new file mode 100644 index 000000000..a43dfcc11 --- /dev/null +++ b/src/components/resource-policy/CreatePolicyOtpEmailSectionForm.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { useTranslations } from "next-intl"; + +import z from "zod"; + +import { type PolicyFormValues } from "."; + +import { SwitchInput } from "@app/components/SwitchInput"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Button } from "@app/components/ui/button"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { InfoPopup } from "@app/components/ui/info-popup"; + +import { InfoIcon, Plus } from "lucide-react"; + +import { useState } from "react"; +import { type UseFormReturn } from "react-hook-form"; + +// ─── CreatePolicyOtpEmailSectionForm ────────────────────────────────────────── + +export type CreatePolicyOtpEmailSectionFormProps = { + form: UseFormReturn; + emailEnabled: boolean; +}; + +export function CreatePolicyOtpEmailSectionForm({ + form, + emailEnabled +}: CreatePolicyOtpEmailSectionFormProps) { + const t = useTranslations(); + const [isOpen, setIsOpen] = useState(false); + const [whitelistEnabled, setWhitelistEnabled] = useState(false); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + + if (!isOpen) { + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + + + ); + } + + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + {!emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + { + setWhitelistEnabled(val); + form.setValue("emailWhitelistEnabled", val); + }} + disabled={!emailEnabled} + /> + + {whitelistEnabled && emailEnabled && ( + ( + + + + + + {/* @ts-ignore */} + { + return z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: t( + "otpEmailErrorInvalid" + ) + } + ) + ) + .safeParse(tag).success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder={t("otpEmailEnter")} + tags={form.getValues().emails} + setTags={(newEmails) => { + form.setValue( + "emails", + newEmails as [Tag, ...Tag[]] + ); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t("otpEmailEnterDescription")} + + + )} + /> + )} + + + + ); +} diff --git a/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx b/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx new file mode 100644 index 000000000..7cb703dad --- /dev/null +++ b/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx @@ -0,0 +1,1073 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; + +import z from "zod"; + +import { type PolicyFormValues } from "."; +import { toast } from "@app/hooks/useToast"; + +import { SwitchInput } from "@app/components/SwitchInput"; +import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { Input } from "@app/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { Switch } from "@app/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; + +import { MAJOR_ASNS } from "@server/db/asns"; +import { COUNTRIES } from "@server/db/countries"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable +} from "@tanstack/react-table"; +import { + ArrowUpDown, + Check, + ChevronsUpDown, + Plus +} from "lucide-react"; + +import { useCallback, useMemo, useState } from "react"; +import { type UseFormReturn, useForm } from "react-hook-form"; + +// ─── CreatePolicyRulesSectionForm ───────────────────────────────────────────── + +const addRuleSchema = z.object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.coerce.number().int().optional() +}); + +type LocalRule = { + ruleId: number; + action: "ACCEPT" | "DROP" | "PASS"; + match: string; + value: string; + priority: number; + enabled: boolean; + new?: boolean; + updated?: boolean; +}; + +export type CreatePolicyRulesSectionFormProps = { + form: UseFormReturn; + isMaxmindAvailable: boolean; + isMaxmindAsnAvailable: boolean; +}; + +export function CreatePolicyRulesSectionForm({ + form, + isMaxmindAvailable, + isMaxmindAsnAvailable +}: CreatePolicyRulesSectionFormProps) { + const t = useTranslations(); + const [isOpen, setIsOpen] = useState(false); + const [rules, setRules] = useState([]); + const [rulesEnabled, setRulesEnabled] = useState(false); + const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = + useState(false); + const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); + + const addRuleForm = useForm({ + resolver: zodResolver(addRuleSchema), + defaultValues: { + action: "ACCEPT" as const, + match: "IP", + value: "" + } + }); + + const RuleAction = useMemo( + () => ({ + ACCEPT: t("alwaysAllow"), + DROP: t("alwaysDeny"), + PASS: t("passToAuth") + }), + [t] + ); + + const RuleMatch = useMemo( + () => ({ + PATH: t("path"), + IP: "IP", + CIDR: t("ipAddressRange"), + COUNTRY: t("country"), + ASN: "ASN" + }), + [t] + ); + + const syncFormRules = useCallback( + (updatedRules: LocalRule[]) => { + form.setValue( + "rules", + updatedRules.map( + ({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + }) + ) + ); + }, + [form] + ); + + const addRule = useCallback( + function addRule(data: z.infer) { + const isDuplicate = rules.some( + (rule) => + rule.action === data.action && + rule.match === data.match && + rule.value === data.value + ); + if (isDuplicate) { + toast({ + variant: "destructive", + title: t("rulesErrorDuplicate"), + description: t("rulesErrorDuplicateDescription") + }); + return; + } + if (data.match === "CIDR" && !isValidCIDR(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddressRange"), + description: t("rulesErrorInvalidIpAddressRangeDescription") + }); + return; + } + if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidUrl"), + description: t("rulesErrorInvalidUrlDescription") + }); + return; + } + if (data.match === "IP" && !isValidIP(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddress"), + description: t("rulesErrorInvalidIpAddressDescription") + }); + return; + } + if ( + data.match === "COUNTRY" && + !COUNTRIES.some((c) => c.code === data.value) + ) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidCountry"), + description: t("rulesErrorInvalidCountryDescription") || "" + }); + return; + } + + let priority = data.priority; + if (priority === undefined) { + priority = + rules.reduce( + (acc, rule) => + rule.priority > acc ? rule.priority : acc, + 0 + ) + 1; + } + + const updatedRules = [ + ...rules, + { + ...data, + ruleId: new Date().getTime(), + new: true, + priority, + enabled: true + } + ]; + setRules(updatedRules); + syncFormRules(updatedRules); + addRuleForm.reset(); + }, + [rules, t, addRuleForm, syncFormRules] + ); + + const removeRule = useCallback( + function removeRule(ruleId: number) { + const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules] + ); + + const updateRule = useCallback( + function updateRule(ruleId: number, data: Partial) { + const updatedRules = rules.map((rule) => + rule.ruleId === ruleId + ? { ...rule, ...data, updated: true } + : rule + ); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules] + ); + + const getValueHelpText = useCallback( + function getValueHelpText(type: string) { + switch (type) { + case "CIDR": + return t("rulesMatchIpAddressRangeDescription"); + case "IP": + return t("rulesMatchIpAddress"); + case "PATH": + return t("rulesMatchUrl"); + case "COUNTRY": + return t("rulesMatchCountry"); + case "ASN": + return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; + } + }, + [t] + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + accessorKey: "priority", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + e.currentTarget.focus()} + onBlur={(e) => { + const parsed = z.coerce + .number() + .int() + .optional() + .safeParse(e.target.value); + if (!parsed.success) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidPriority"), + description: t( + "rulesErrorInvalidPriorityDescription" + ) + }); + return; + } + updateRule(row.original.ruleId, { + priority: parsed.data + }); + }} + /> + ) + }, + { + accessorKey: "action", + header: () => {t("rulesAction")}, + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "match", + header: () => ( + {t("rulesMatchType")} + ), + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "value", + header: () => {t("value")}, + cell: ({ row }) => + row.original.match === "COUNTRY" ? ( + + + + + + + + + + {t("noCountryFound")} + + + {COUNTRIES.map((country) => ( + + updateRule( + row.original.ruleId, + { + value: country.code + } + ) + } + > + + {country.name} ( + {country.code}) + + ))} + + + + + + ) : row.original.match === "ASN" ? ( + + + + + + + + + + No ASN found. Enter a custom ASN + below. + + + {MAJOR_ASNS.map((asn) => ( + + updateRule( + row.original.ruleId, + { value: asn.code } + ) + } + > + + {asn.name} ({asn.code}) + + ))} + + + +
+ + asn.code === + row.original.value + ) + ? row.original.value + : "" + } + onKeyDown={(e) => { + if (e.key === "Enter") { + const value = + e.currentTarget.value + .toUpperCase() + .replace(/^AS/, ""); + if (/^\d+$/.test(value)) { + updateRule( + row.original.ruleId, + { value: "AS" + value } + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + updateRule(row.original.ruleId, { + value: e.target.value + }) + } + /> + ) + }, + { + accessorKey: "enabled", + header: () => {t("enabled")}, + cell: ({ row }) => ( + + updateRule(row.original.ruleId, { enabled: val }) + } + /> + ) + }, + { + id: "actions", + header: () => {t("actions")}, + cell: ({ row }) => ( +
+ +
+ ) + } + ], + [ + t, + RuleAction, + RuleMatch, + isMaxmindAvailable, + isMaxmindAsnAvailable, + updateRule, + removeRule + ] + ); + + const table = useReactTable({ + data: rules, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { pagination: { pageIndex: 0, pageSize: 1000 } } + }); + + if (!isOpen) { + return ( + + + + {t("rulesResource")} + + + {t("rulesResourcePolicyDescription")} + + + + + + + ); + } + + return ( + + + + {t("rulesResource")} + + + {t("rulesResourceDescription")} + + + +
+
+ { + setRulesEnabled(val); + form.setValue("applyRules", val); + }} + /> +
+ +
+ +
+ ( + + + {t("rulesAction")} + + + + + + + )} + /> + ( + + + {t("rulesMatchType")} + + + + + + + )} + /> + ( + + + + {addRuleForm.watch("match") === + "COUNTRY" ? ( + + + + + + + + + + {t( + "noCountryFound" + )} + + + {COUNTRIES.map( + ( + country + ) => ( + { + field.onChange( + country.code + ); + setOpenAddRuleCountrySelect( + false + ); + }} + > + + { + country.name + }{" "} + ( + { + country.code + } + + ) + + ) + )} + + + + + + ) : addRuleForm.watch( + "match" + ) === "ASN" ? ( + + + + + + + + + + No ASN + found. + Use the + custom + input + below. + + + {MAJOR_ASNS.map( + ( + asn + ) => ( + { + field.onChange( + asn.code + ); + setOpenAddRuleAsnSelect( + false + ); + }} + > + + { + asn.name + }{" "} + ( + { + asn.code + } + + ) + + ) + )} + + + +
+ { + if ( + e.key === + "Enter" + ) { + const value = + e.currentTarget.value + .toUpperCase() + .replace( + /^AS/, + "" + ); + if ( + /^\d+$/.test( + value + ) + ) { + field.onChange( + "AS" + + value + ); + setOpenAddRuleAsnSelect( + false + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + )} +
+ +
+ )} + /> + +
+
+ + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isActionsColumn = + header.column.id === "actions"; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column + .columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const isActionsColumn = + cell.column.id === "actions"; + return ( + + {flexRender( + cell.column.columnDef + .cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + + {t("rulesNoOne")} + + + )} + +
+
+
+
+ ); +} diff --git a/src/components/resource-policy/CreatePolicyUserRolesSectionForm.tsx b/src/components/resource-policy/CreatePolicyUserRolesSectionForm.tsx new file mode 100644 index 000000000..48d8b94f8 --- /dev/null +++ b/src/components/resource-policy/CreatePolicyUserRolesSectionForm.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { SwitchInput } from "@app/components/SwitchInput"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { type PolicyFormValues } from "."; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { type UseFormReturn, useWatch } from "react-hook-form"; + +// ─── CreatePolicyUsersRolesSectionForm ──────────────────────────────────────── + +export type CreatePolicyUsersRolesSectionFormProps = { + form: UseFormReturn; + allRoles: { id: string; text: string }[]; + allUsers: { id: string; text: string }[]; + allIdps: { id: number; text: string }[]; +}; + +export function CreatePolicyUsersRolesSectionForm({ + form, + allRoles, + allUsers, + allIdps +}: CreatePolicyUsersRolesSectionFormProps) { + const t = useTranslations(); + const ssoEnabled = useWatch({ control: form.control, name: "sso" }); + const selectedIdpId = useWatch({ + control: form.control, + name: "skipToIdpId" + }); + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< + number | null + >(null); + const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< + number | null + >(null); + + return ( + + + + {t("resourceUsersRoles")} + + + {t("resourcePolicyUsersRolesDescription")} + + + + + { + console.log(`form.setValue("sso", ${val})`); + form.setValue("sso", val); + }} + /> + + {ssoEnabled && ( + <> + ( + + {t("roles")} + + { + form.setValue( + "roles", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allRoles} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + {t("resourceRoleDescription")} + + + )} + /> + ( + + {t("users")} + + { + form.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allUsers} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + + )} + + {ssoEnabled && allIdps.length > 0 && ( +
+ + +

+ {t("defaultIdentityProviderDescription")} +

+
+ )} +
+
+
+ ); +} From c5fc49b4fae0d15ab0c68434d086947ee3cc89db Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 5 Mar 2026 19:31:19 +0100 Subject: [PATCH 58/89] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatePolicyAuthMethodsSectionForm.tsx | 30 ++++++++++++------- .../CreatePolicyOtpEmailSectionForm.tsx | 6 ++-- .../CreatePolicyRulesSectionForm.tsx | 6 ++-- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/components/resource-policy/CreatePolicyAuthMethodsSectionForm.tsx b/src/components/resource-policy/CreatePolicyAuthMethodsSectionForm.tsx index fb456e823..f881104cb 100644 --- a/src/components/resource-policy/CreatePolicyAuthMethodsSectionForm.tsx +++ b/src/components/resource-policy/CreatePolicyAuthMethodsSectionForm.tsx @@ -43,10 +43,11 @@ import { InputOTPSlot } from "@app/components/ui/input-otp"; +import { cn } from "@app/lib/cn"; import { Binary, Bot, Key, Plus } from "lucide-react"; import { useState } from "react"; -import { type UseFormReturn, useForm } from "react-hook-form"; +import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; // ─── CreatePolicyAuthMethodsSectionForm ─────────────────────────────────────── @@ -72,14 +73,23 @@ export function CreatePolicyAuthMethodsSectionForm({ form }: CreatePolicyAuthMethodsSectionFormProps) { const t = useTranslations(); - const [isOpen, setIsOpen] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); - const password = form.watch("password"); - const pincode = form.watch("pincode"); - const headerAuth = form.watch("headerAuth"); + const password = useWatch({ + control: form.control, + name: "password" + }); + const pincode = useWatch({ + control: form.control, + name: "pincode" + }); + const headerAuth = useWatch({ + control: form.control, + name: "headerAuth" + }); const passwordForm = useForm({ resolver: zodResolver(setPasswordSchema), @@ -96,7 +106,7 @@ export function CreatePolicyAuthMethodsSectionForm({ defaultValues: { user: "", password: "", extendedCompatibility: true } }); - if (!isOpen) { + if (!isExpanded) { return ( @@ -111,7 +121,7 @@ export function CreatePolicyAuthMethodsSectionForm({ - ); } diff --git a/src/components/resource-policy/CreatePolicyOtpEmailSectionForm.tsx b/src/components/resource-policy/CreatePolicyOtpEmailSectionForm.tsx index ce8ac54b9..fb324cced 100644 --- a/src/components/resource-policy/CreatePolicyOtpEmailSectionForm.tsx +++ b/src/components/resource-policy/CreatePolicyOtpEmailSectionForm.tsx @@ -9,30 +9,31 @@ import { SettingsSectionTitle } from "@app/components/Settings"; +import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslations } from "next-intl"; import z from "zod"; -import { type PolicyFormValues } from "."; +import { createPolicySchema, type PolicyFormValues } from "."; import { SwitchInput } from "@app/components/SwitchInput"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; import { + Form, FormControl, FormDescription, FormField, FormItem, - FormLabel, - FormMessage + FormLabel } from "@app/components/ui/form"; import { InfoPopup } from "@app/components/ui/info-popup"; import { InfoIcon, Plus } from "lucide-react"; -import { useState } from "react"; -import { type UseFormReturn } from "react-hook-form"; +import { useEffect, useState } from "react"; +import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; // ─── CreatePolicyOtpEmailSectionForm ────────────────────────────────────────── @@ -42,16 +43,44 @@ export type CreatePolicyOtpEmailSectionFormProps = { }; export function CreatePolicyOtpEmailSectionForm({ - form, + form: parentForm, emailEnabled }: CreatePolicyOtpEmailSectionFormProps) { const t = useTranslations(); const [isExpanded, setIsExpanded] = useState(false); - const [whitelistEnabled, setWhitelistEnabled] = useState(false); const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< number | null >(null); + const form = useForm({ + resolver: zodResolver( + createPolicySchema.pick({ + emailWhitelistEnabled: true, + emails: true + }) + ), + defaultValues: { + emailWhitelistEnabled: false, + emails: [] + } + }); + + useEffect(() => { + const subscription = form.watch((values) => { + parentForm.setValue( + "emailWhitelistEnabled", + values.emailWhitelistEnabled as boolean + ); + parentForm.setValue("emails", values.emails as [Tag, ...Tag[]]); + }); + return () => subscription.unsubscribe(); + }, [form, parentForm]); + + const whitelistEnabled = useWatch({ + control: form.control, + name: "emailWhitelistEnabled" + }); + if (!isExpanded) { return ( @@ -78,100 +107,107 @@ export function CreatePolicyOtpEmailSectionForm({ } return ( - - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - - {!emailEnabled && ( - - - - {t("otpEmailSmtpRequired")} - - - {t("otpEmailSmtpRequiredDescription")} - - - )} - { - setWhitelistEnabled(val); - form.setValue("emailWhitelistEnabled", val); - }} - disabled={!emailEnabled} - /> - - {whitelistEnabled && emailEnabled && ( - ( - - - - - - {/* @ts-ignore */} - { - return z - .email() - .or( - z - .string() - .regex( - /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, - { - message: t( - "otpEmailErrorInvalid" - ) - } - ) - ) - .safeParse(tag).success; - }} - setActiveTagIndex={ - setActiveEmailTagIndex - } - placeholder={t("otpEmailEnter")} - tags={form.getValues().emails} - setTags={(newEmails) => { - form.setValue( - "emails", - newEmails as [Tag, ...Tag[]] - ); - }} - allowDuplicates={false} - sortTags={true} - /> - - - {t("otpEmailEnterDescription")} - - - )} +
+ + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + {!emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + { + form.setValue("emailWhitelistEnabled", val); + }} + disabled={!emailEnabled} /> - )} - - - + + {whitelistEnabled && emailEnabled && ( + ( + + + + + + {/* @ts-ignore */} + { + return z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: + t( + "otpEmailErrorInvalid" + ) + } + ) + ) + .safeParse(tag).success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder={t("otpEmailEnter")} + tags={form.getValues().emails} + setTags={(newEmails) => { + form.setValue( + "emails", + newEmails as [ + Tag, + ...Tag[] + ] + ); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t("otpEmailEnterDescription")} + + + )} + /> + )} + + + + ); } diff --git a/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx b/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx index 87152fa09..c8635c5a3 100644 --- a/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx +++ b/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx @@ -13,7 +13,7 @@ import { useTranslations } from "next-intl"; import z from "zod"; -import { type PolicyFormValues } from "."; +import { createPolicySchema, type PolicyFormValues } from "."; import { toast } from "@app/hooks/useToast"; import { SwitchInput } from "@app/components/SwitchInput"; @@ -81,8 +81,8 @@ import { Plus } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; -import { type UseFormReturn, useForm } from "react-hook-form"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; // ─── CreatePolicyRulesSectionForm ───────────────────────────────────────────── @@ -111,18 +111,43 @@ export type CreatePolicyRulesSectionFormProps = { }; export function CreatePolicyRulesSectionForm({ - form, + form: parentForm, isMaxmindAvailable, isMaxmindAsnAvailable }: CreatePolicyRulesSectionFormProps) { const t = useTranslations(); const [isExpanded, setIsExpanded] = useState(false); const [rules, setRules] = useState([]); - const [rulesEnabled, setRulesEnabled] = useState(false); const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = useState(false); const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); + const form = useForm({ + resolver: zodResolver( + createPolicySchema.pick({ + applyRules: true, + rules: true + }) + ), + defaultValues: { + applyRules: false, + rules: [] + } + }); + + useEffect(() => { + const subscription = form.watch((values) => { + parentForm.setValue("applyRules", values.applyRules as boolean); + parentForm.setValue("rules", values.rules as any); + }); + return () => subscription.unsubscribe(); + }, [form, parentForm]); + + const rulesEnabled = useWatch({ + control: form.control, + name: "applyRules" + }); + const addRuleForm = useForm({ resolver: zodResolver(addRuleSchema), defaultValues: { @@ -656,7 +681,6 @@ export function CreatePolicyRulesSectionForm({ label={t("rulesEnable")} defaultChecked={false} onCheckedChange={(val) => { - setRulesEnabled(val); form.setValue("applyRules", val); }} /> diff --git a/src/components/resource-policy/CreatePolicyUserRolesSectionForm.tsx b/src/components/resource-policy/CreatePolicyUserRolesSectionForm.tsx index 48d8b94f8..132363fc1 100644 --- a/src/components/resource-policy/CreatePolicyUserRolesSectionForm.tsx +++ b/src/components/resource-policy/CreatePolicyUserRolesSectionForm.tsx @@ -9,9 +9,11 @@ import { SettingsSectionTitle } from "@app/components/Settings"; +import { zodResolver } from "@hookform/resolvers/zod"; import { SwitchInput } from "@app/components/SwitchInput"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { + Form, FormControl, FormDescription, FormField, @@ -26,10 +28,10 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; -import { type PolicyFormValues } from "."; +import { createPolicySchema, type PolicyFormValues } from "."; import { useTranslations } from "next-intl"; -import { useState } from "react"; -import { type UseFormReturn, useWatch } from "react-hook-form"; +import { useEffect, useState } from "react"; +import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; // ─── CreatePolicyUsersRolesSectionForm ──────────────────────────────────────── @@ -41,12 +43,40 @@ export type CreatePolicyUsersRolesSectionFormProps = { }; export function CreatePolicyUsersRolesSectionForm({ - form, + form: parentForm, allRoles, allUsers, allIdps }: CreatePolicyUsersRolesSectionFormProps) { const t = useTranslations(); + + const form = useForm({ + resolver: zodResolver( + createPolicySchema.pick({ + sso: true, + skipToIdpId: true, + roles: true, + users: true + }) + ), + defaultValues: { + sso: true, + skipToIdpId: null, + roles: [], + users: [] + } + }); + + useEffect(() => { + const subscription = form.watch((values) => { + parentForm.setValue("sso", values.sso as boolean); + parentForm.setValue("skipToIdpId", values.skipToIdpId as number | null); + parentForm.setValue("roles", values.roles as [Tag, ...Tag[]]); + parentForm.setValue("users", values.users as [Tag, ...Tag[]]); + }); + return () => subscription.unsubscribe(); + }, [form, parentForm]); + const ssoEnabled = useWatch({ control: form.control, name: "sso" }); const selectedIdpId = useWatch({ control: form.control, @@ -60,165 +90,168 @@ export function CreatePolicyUsersRolesSectionForm({ >(null); return ( - - - - {t("resourceUsersRoles")} - - - {t("resourcePolicyUsersRolesDescription")} - - - - - { - console.log(`form.setValue("sso", ${val})`); - form.setValue("sso", val); - }} - /> +
+ + + + {t("resourceUsersRoles")} + + + {t("resourcePolicyUsersRolesDescription")} + + + + + { + form.setValue("sso", val); + }} + /> - {ssoEnabled && ( - <> - ( - - {t("roles")} - - { - form.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={true} - autocompleteOptions={allRoles} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - {t("resourceRoleDescription")} - - - )} - /> - ( - - {t("users")} - - { - form.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={true} - autocompleteOptions={allUsers} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - - )} + {ssoEnabled && ( + <> + ( + + {t("roles")} + + { + form.setValue( + "roles", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allRoles} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + {t("resourceRoleDescription")} + + + )} + /> + ( + + {t("users")} + + { + form.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allUsers} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + + )} - {ssoEnabled && allIdps.length > 0 && ( -
- - { + if (value === "none") { + form.setValue("skipToIdpId", null); + } else { + const id = parseInt(value); + form.setValue("skipToIdpId", id); + } + }} + value={ + selectedIdpId + ? selectedIdpId.toString() + : "none" } - }} - value={ - selectedIdpId - ? selectedIdpId.toString() - : "none" - } - > - - - - - - {t("none")} - - {allIdps.map((idp) => ( - - {idp.text} + > + + + + + + {t("none")} - ))} - - -

- {t("defaultIdentityProviderDescription")} -

-
- )} -
-
-
+ {allIdps.map((idp) => ( + + {idp.text} + + ))} + + +

+ {t("defaultIdentityProviderDescription")} +

+ + )} + + + +
); } From 136c3eff0cef5a55a40089935998a34389a2773c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 5 Mar 2026 19:46:16 +0100 Subject: [PATCH 60/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20padding=20bottom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resource-policy/CreatePolicyRulesSectionForm.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx b/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx index c8635c5a3..b3285b284 100644 --- a/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx +++ b/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx @@ -74,12 +74,7 @@ import { getSortedRowModel, useReactTable } from "@tanstack/react-table"; -import { - ArrowUpDown, - Check, - ChevronsUpDown, - Plus -} from "lucide-react"; +import { ArrowUpDown, Check, ChevronsUpDown, Plus } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; @@ -674,8 +669,8 @@ export function CreatePolicyRulesSectionForm({
-
-
+
+
Date: Fri, 6 Mar 2026 04:03:25 +0100 Subject: [PATCH 61/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20show=20list=20of=20r?= =?UTF-8?q?esources=20on=20policy=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routers/policy/listResourcePolicies.ts | 54 +++++++++++++++++-- server/routers/resource/types.ts | 13 +++-- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/server/private/routers/policy/listResourcePolicies.ts b/server/private/routers/policy/listResourcePolicies.ts index 72d4ff39d..a10cfb4fb 100644 --- a/server/private/routers/policy/listResourcePolicies.ts +++ b/server/private/routers/policy/listResourcePolicies.ts @@ -11,11 +11,20 @@ * This file is not licensed under the AGPLv3. */ -import { db, resourcePolicies, rolePolicies, userPolicies } from "@server/db"; +import { + db, + resourcePolicies, + resources, + rolePolicies, + userPolicies +} from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; +import type { + ListResourcePoliciesResponse, + ResourcePolicyWithResources +} from "@server/routers/resource/types"; import HttpCode from "@server/types/HttpCode"; import { and, asc, eq, inArray, like, or, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; @@ -191,9 +200,48 @@ export async function listResourcePolicies( countQuery ]); + const attachedResources = + rows.length === 0 + ? [] + : await db + .select({ + resourceId: resources.resourceId, + name: resources.name, + fullDomain: resources.fullDomain, + resourcePolicyId: resources.resourcePolicyId + }) + .from(resources) + .where( + inArray( + resources.resourcePolicyId, + rows.map((row) => row.resourcePolicyId) + ) + ); + const entries: ResourcePolicyWithResources[] = []; + + // avoids TS issues with reduce/never[] + const map = new Map(); + + for (const row of rows) { + let entry = map.get(row.resourcePolicyId); + if (!entry) { + entry = { + ...row, + resources: [] + }; + map.set(row.resourcePolicyId, entry); + } + + entry.resources = attachedResources.filter( + (r) => r.resourcePolicyId === entry?.resourcePolicyId + ); + } + + const policiesList = Array.from(map.values()); + return response(res, { data: { - policies: rows, + policies: policiesList, pagination: { total: totalCount, pageSize, diff --git a/server/routers/resource/types.ts b/server/routers/resource/types.ts index 223154a01..c79e78d69 100644 --- a/server/routers/resource/types.ts +++ b/server/routers/resource/types.ts @@ -1,4 +1,4 @@ -import type { ResourcePolicy } from "@server/db"; +import type { Resource, ResourcePolicy } from "@server/db"; import type { PaginatedResponse } from "@server/types/Pagination"; export type GetMaintenanceInfoResponse = { @@ -12,8 +12,13 @@ export type GetMaintenanceInfoResponse = { maintenanceEstimatedTime: string | null; }; +export type ResourcePolicyWithResources = Pick< + ResourcePolicy, + "resourcePolicyId" | "niceId" | "name" | "orgId" +> & { + resources: Array>; +}; + export type ListResourcePoliciesResponse = PaginatedResponse<{ - policies: Array< - Pick - >; + policies: Array; }>; From dfe42e90168104c7aa144de2323e2068a68b50de Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Mar 2026 04:03:40 +0100 Subject: [PATCH 62/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/private/routers/policy/listResourcePolicies.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/private/routers/policy/listResourcePolicies.ts b/server/private/routers/policy/listResourcePolicies.ts index a10cfb4fb..58a83df04 100644 --- a/server/private/routers/policy/listResourcePolicies.ts +++ b/server/private/routers/policy/listResourcePolicies.ts @@ -217,7 +217,6 @@ export async function listResourcePolicies( rows.map((row) => row.resourcePolicyId) ) ); - const entries: ResourcePolicyWithResources[] = []; // avoids TS issues with reduce/never[] const map = new Map(); From 37ceba6b815cadd0ce2d5b6f9678ab90b0380247 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Mar 2026 04:36:12 +0100 Subject: [PATCH 63/89] =?UTF-8?q?=F0=9F=92=84=20show=20attached=20resource?= =?UTF-8?q?s=20in=20policy=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 4 +- server/routers/resource/types.ts | 7 +- .../(private)/policies/resource/page.tsx | 1 - src/components/ResourcePoliciesTable.tsx | 84 ++++++++++++++++++- 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index af2eef9cb..72e8baa5e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -167,6 +167,9 @@ "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", "resourcePoliciesTitle": "Manage Resource Policies", + "resourcePoliciesAttachedResourcesColumnTitle": "Attached resources", + "resourcePoliciesAttachedResources": "{count} resource(s)", + "resourcePoliciesAttachedResourcesEmpty": "no resources", "resourcePoliciesDescription": "Create and manage authentication policies to control access to your resources", "resourcePoliciesSearch": "Search policies...", "resourcePoliciesAdd": "Add Policy", @@ -1069,7 +1072,6 @@ "pageNotFoundDescription": "Oops! The page you're looking for doesn't exist.", "overview": "Overview", "home": "Home", - "accessControl": "Access Control", "settings": "Settings", "usersAll": "All Users", "license": "License", diff --git a/server/routers/resource/types.ts b/server/routers/resource/types.ts index c79e78d69..eee70bd35 100644 --- a/server/routers/resource/types.ts +++ b/server/routers/resource/types.ts @@ -12,11 +12,16 @@ export type GetMaintenanceInfoResponse = { maintenanceEstimatedTime: string | null; }; +export type AttachedResource = Pick< + Resource, + "resourceId" | "name" | "fullDomain" +>; + export type ResourcePolicyWithResources = Pick< ResourcePolicy, "resourcePolicyId" | "niceId" | "name" | "orgId" > & { - resources: Array>; + resources: Array; }; export type ListResourcePoliciesResponse = PaginatedResponse<{ diff --git a/src/app/[orgId]/settings/(private)/policies/resource/page.tsx b/src/app/[orgId]/settings/(private)/policies/resource/page.tsx index 3f2ec53b0..a51bbef3a 100644 --- a/src/app/[orgId]/settings/(private)/policies/resource/page.tsx +++ b/src/app/[orgId]/settings/(private)/policies/resource/page.tsx @@ -3,7 +3,6 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; -import OrgProvider from "@app/providers/OrgProvider"; import type { GetOrgResponse } from "@server/routers/org"; import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; import type { AxiosResponse } from "axios"; diff --git a/src/components/ResourcePoliciesTable.tsx b/src/components/ResourcePoliciesTable.tsx index 69dee6963..5dfc007df 100644 --- a/src/components/ResourcePoliciesTable.tsx +++ b/src/components/ResourcePoliciesTable.tsx @@ -3,9 +3,17 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; -import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; +import type { + AttachedResource, + ListResourcePoliciesResponse +} from "@server/routers/resource/types"; import type { PaginationState } from "@tanstack/react-table"; -import { ArrowRight, MoreHorizontal } from "lucide-react"; +import { + ArrowRight, + ChevronDown, + MoreHorizontal, + Waypoints +} from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -20,6 +28,8 @@ import { DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; +import type { targets } from "@server/db"; +import type { TargetHealth } from "./ProxyResourcesTable"; type ResourcePolicyRow = ListResourcePoliciesResponse["policies"][number]; @@ -65,6 +75,63 @@ export function ResourcePoliciesTable({ }); }; + function ResourceListCell({ + resources + }: { + resources?: AttachedResource[]; + }) { + if (!resources || resources.length === 0) { + return ( +
+ + + {t("resourcePoliciesAttachedResourcesEmpty")} + +
+ ); + } + + return ( + + + + + + {resources.map((resource) => ( + +
+ {resource.name} +
+ + {resource.fullDomain} + +
+ ))} +
+
+ ); + } + const proxyColumns: ExtendedColumnDef[] = [ { accessorKey: "name", @@ -83,6 +150,19 @@ export function ResourcePoliciesTable({ return {row.original.niceId || "-"}; } }, + { + id: "resources", + accessorKey: "resources", + friendlyName: t("resourcePoliciesAttachedResourcesColumnTitle"), + header: () => ( + + {t("resourcePoliciesAttachedResourcesColumnTitle")} + + ), + cell: ({ row }) => { + return ; + } + }, { id: "actions", enableHiding: false, From bcd6cd99ccc80a5863f80e2af43696a1342c4433 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Mar 2026 04:37:57 +0100 Subject: [PATCH 64/89] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/private/routers/policy/deleteResourcePolicy.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 server/private/routers/policy/deleteResourcePolicy.ts diff --git a/server/private/routers/policy/deleteResourcePolicy.ts b/server/private/routers/policy/deleteResourcePolicy.ts new file mode 100644 index 000000000..e69de29bb From 9b43948fa4882d07ad63b4c9a896882bf9e2dadd Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Mar 2026 22:39:44 +0100 Subject: [PATCH 65/89] =?UTF-8?q?=E2=9C=A8=20=20delete=20resource=20policy?= =?UTF-8?q?=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 2 +- server/private/routers/external.ts | 15 +++- .../routers/policy/deleteResourcePolicy.ts | 86 +++++++++++++++++++ server/private/routers/policy/index.ts | 1 + server/routers/resource/deleteResource.ts | 18 ++-- 5 files changed, 109 insertions(+), 13 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 934dbb6da..c20ec303a 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -100,7 +100,7 @@ export const resources = pgTable("resources", { resourceId: serial("resourceId").primaryKey(), resourcePolicyId: integer("resourcePolicyId").references( () => resourcePolicies.resourcePolicyId, - { onDelete: "cascade" } + { onDelete: "set null" } ), resourceGuid: varchar("resourceGuid", { length: 36 }) .unique() diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index e0d8f240f..688f565d4 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -35,7 +35,8 @@ import { verifyUserIsServerAdmin, verifySiteAccess, verifyClientAccess, - verifyLimits + verifyLimits, + verifyResourcePolicyAccess } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { @@ -354,6 +355,18 @@ authenticated.get( policy.listResourcePolicies ); +authenticated.delete( + "/resource-policy/:resourcePolicyId", + verifyResourcePolicyAccess, + verifyValidLicense, + // verifyValidSubscription(tierMatrix.loginPageDomain), // todo: use the correct subscription ? + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.deleteResourcePolicy), + logActionAudit(ActionsEnum.deleteResourcePolicy), + policy.deleteResourcePolicy +); + authenticated.post( "/org/:orgId/resource-policy", verifyValidLicense, diff --git a/server/private/routers/policy/deleteResourcePolicy.ts b/server/private/routers/policy/deleteResourcePolicy.ts index e69de29bb..bb5efb1f3 100644 --- a/server/private/routers/policy/deleteResourcePolicy.ts +++ b/server/private/routers/policy/deleteResourcePolicy.ts @@ -0,0 +1,86 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { db, resourcePolicies } from "@server/db"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import { eq } from "drizzle-orm"; +import type { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import z from "zod"; +import { fromError } from "zod-validation-error"; + +// Define Zod schema for request parameters validation +const deleteResourcePolicySchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "delete", + path: "/resource/{resourceId}", + description: "Delete a resource.", + tags: [OpenAPITags.PublicResource], + request: { + params: deleteResourcePolicySchema + }, + responses: {} +}); + +export async function deleteResourcePolicy( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = deleteResourcePolicySchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourcePolicyId } = parsedParams.data; + + const [deletedResource] = await db + .delete(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .returning(); + + if (!deletedResource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource Policy with ID ${resourcePolicyId} not found` + ) + ); + } + + return response(res, { + data: null, + success: true, + error: false, + message: "Resource Policy deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/policy/index.ts b/server/private/routers/policy/index.ts index 88302bcac..1fb73a58c 100644 --- a/server/private/routers/policy/index.ts +++ b/server/private/routers/policy/index.ts @@ -13,3 +13,4 @@ export * from "./createResourcePolicy"; export * from "./listResourcePolicies"; +export * from "./deleteResourcePolicy"; diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index e63301867..f69853a90 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -1,17 +1,13 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db } from "@server/db"; -import { newts, resources, sites, targets } from "@server/db"; -import { eq } from "drizzle-orm"; +import { db, resources, targets } from "@server/db"; 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 { addPeer } from "../gerbil/peers"; -import { removeTargets } from "../newt/targets"; -import { getAllowedIps } from "../target/helpers"; import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import { eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; // Define Zod schema for request parameters validation const deleteResourceSchema = z.strictObject({ From 884482ec35cf87a348ee65057c1d0f190f8a5d0c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Mar 2026 23:57:23 +0100 Subject: [PATCH 66/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20delete=20resource=20?= =?UTF-8?q?policy=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 4 ++ .../routers/policy/deleteResourcePolicy.ts | 6 +- src/components/ResourcePoliciesTable.tsx | 55 +++++++++++++++++-- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 72e8baa5e..a6d2d36c4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -191,6 +191,8 @@ "notProtected": "Not Protected", "resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.", "resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?", + "resourcePolicyMessageRemove": "Once removed, the resource policy will no longer be accessible. All resources associated with the resource will be unlinked and left without authentication.", + "resourcePolicyQuestionRemove": "Are you sure you want to remove the resource policy from the organization?", "resourceHTTP": "HTTPS Resource", "resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.", "resourceRaw": "Raw TCP/UDP Resource", @@ -231,6 +233,8 @@ "resourceLearnRaw": "Learn how to configure TCP/UDP resources", "resourceBack": "Back to Resources", "resourceGoTo": "Go to Resource", + "resourcePolicyDelete": "Delete Resource Policy", + "resourcePolicyDeleteConfirm": "Confirm Delete Resource Policy", "resourceDelete": "Delete Resource", "resourceDeleteConfirm": "Confirm Delete Resource", "visibility": "Visibility", diff --git a/server/private/routers/policy/deleteResourcePolicy.ts b/server/private/routers/policy/deleteResourcePolicy.ts index bb5efb1f3..d7887e3a0 100644 --- a/server/private/routers/policy/deleteResourcePolicy.ts +++ b/server/private/routers/policy/deleteResourcePolicy.ts @@ -29,9 +29,9 @@ const deleteResourcePolicySchema = z.strictObject({ registry.registerPath({ method: "delete", - path: "/resource/{resourceId}", - description: "Delete a resource.", - tags: [OpenAPITags.PublicResource], + path: "/resource-policy/{resourcePolicyId}", + description: "Delete a resource policy.", + tags: [OpenAPITags.Policy], request: { params: deleteResourcePolicySchema }, diff --git a/src/components/ResourcePoliciesTable.tsx b/src/components/ResourcePoliciesTable.tsx index 5dfc007df..bfdc49724 100644 --- a/src/components/ResourcePoliciesTable.tsx +++ b/src/components/ResourcePoliciesTable.tsx @@ -2,7 +2,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { toast } from "@app/hooks/useToast"; -import { createApiClient } from "@app/lib/api"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; import type { AttachedResource, ListResourcePoliciesResponse @@ -17,7 +17,7 @@ import { import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useTransition } from "react"; +import { useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; import { Button } from "./ui/button"; import { ControlledDataTable } from "./ui/controlled-data-table"; @@ -28,8 +28,7 @@ import { DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; -import type { targets } from "@server/db"; -import type { TargetHealth } from "./ProxyResourcesTable"; +import ConfirmDeleteDialog from "./ConfirmDeleteDialog"; type ResourcePolicyRow = ListResourcePoliciesResponse["policies"][number]; @@ -58,6 +57,27 @@ export function ResourcePoliciesTable({ const api = createApiClient({ env }); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedResourcePolicy, setSelectedResourcePolicy] = + useState(null); + + const deleteResourcePolicy = async (resourcePolicyId: number) => { + await api + .delete(`/resource-policy/${resourcePolicyId}`) + .catch((e) => { + console.error(t("resourceErrorDelte"), e); + toast({ + variant: "destructive", + title: t("resourceErrorDelte"), + description: formatAxiosError(e, t("resourceErrorDelte")) + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + }); + }; + const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); @@ -191,8 +211,8 @@ export function ResourcePoliciesTable({ { - // setSelectedResource(resourceRow); - // setIsDeleteModalOpen(true); + setSelectedResourcePolicy(policyRow); + setIsDeleteModalOpen(true); }} > @@ -233,6 +253,29 @@ export function ResourcePoliciesTable({ return ( <> + {selectedResourcePolicy && ( + { + setIsDeleteModalOpen(val); + setSelectedResourcePolicy(null); + }} + dialog={ +
+

{t("resourcePolicyQuestionRemove")}

+

{t("resourcePolicyMessageRemove")}

+
+ } + buttonText={t("resourcePolicyDeleteConfirm")} + onConfirm={async () => + deleteResourcePolicy( + selectedResourcePolicy.resourcePolicyId + ) + } + string={selectedResourcePolicy.name} + title={t("resourcePolicyDelete")} + /> + )} Date: Sat, 7 Mar 2026 01:12:10 +0100 Subject: [PATCH 67/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20prevent=20deleting?= =?UTF-8?q?=20resource=20policies=20if=20they=20have=20attached=20resource?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 6 ++++ server/private/routers/external.ts | 23 +++++++------ .../routers/policy/deleteResourcePolicy.ts | 33 +++++++++++++++---- 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index c20ec303a..35a1d21a3 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -102,6 +102,12 @@ export const resources = pgTable("resources", { () => resourcePolicies.resourcePolicyId, { onDelete: "set null" } ), + defaultResourcePolicyId: integer("defaultResourcePolicyId").references( + () => resourcePolicies.resourcePolicyId, + { + onDelete: "restrict" + } + ), resourceGuid: varchar("resourceGuid", { length: 36 }) .unique() .notNull() diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 688f565d4..f244a8a10 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -344,6 +344,17 @@ authenticated.get( approval.countApprovals ); +authenticated.delete( + "/resource-policy/:resourcePolicyId", + verifyResourcePolicyAccess, + verifyValidLicense, + // verifyValidSubscription(tierMatrix.loginPageDomain), // todo: use the correct subscription ? + verifyLimits, + verifyUserHasAction(ActionsEnum.deleteResourcePolicy), + logActionAudit(ActionsEnum.deleteResourcePolicy), + policy.deleteResourcePolicy +); + authenticated.get( "/org/:orgId/resource-policies", verifyValidLicense, @@ -355,18 +366,6 @@ authenticated.get( policy.listResourcePolicies ); -authenticated.delete( - "/resource-policy/:resourcePolicyId", - verifyResourcePolicyAccess, - verifyValidLicense, - // verifyValidSubscription(tierMatrix.loginPageDomain), // todo: use the correct subscription ? - verifyOrgAccess, - verifyLimits, - verifyUserHasAction(ActionsEnum.deleteResourcePolicy), - logActionAudit(ActionsEnum.deleteResourcePolicy), - policy.deleteResourcePolicy -); - authenticated.post( "/org/:orgId/resource-policy", verifyValidLicense, diff --git a/server/private/routers/policy/deleteResourcePolicy.ts b/server/private/routers/policy/deleteResourcePolicy.ts index d7887e3a0..17a9a68f9 100644 --- a/server/private/routers/policy/deleteResourcePolicy.ts +++ b/server/private/routers/policy/deleteResourcePolicy.ts @@ -11,7 +11,7 @@ * This file is not licensed under the AGPLv3. */ -import { db, resourcePolicies } from "@server/db"; +import { db, resourcePolicies, resources } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -56,12 +56,12 @@ export async function deleteResourcePolicy( const { resourcePolicyId } = parsedParams.data; - const [deletedResource] = await db - .delete(resourcePolicies) - .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) - .returning(); + const [existingResource] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)); - if (!deletedResource) { + if (!existingResource) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -70,6 +70,27 @@ export async function deleteResourcePolicy( ); } + const totalAffectedResources = await db.$count( + db + .select() + .from(resources) + .where(eq(resources.resourcePolicyId, resourcePolicyId)) + ); + + if (totalAffectedResources > 0) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `Cannot delete Policy '${existingResource.name}' as it's being used by at least one resource` + ) + ); + } + + // delete policy + await db + .delete(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)); + return response(res, { data: null, success: true, From 5d956080f2ab2d3dd349e9071294a102c9e6fe0b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 7 Mar 2026 02:29:36 +0100 Subject: [PATCH 68/89] =?UTF-8?q?=E2=9C=A8=20=20create=20default=20policy?= =?UTF-8?q?=20when=20creating=20a=20resource?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/resource/createResource.ts | 115 ++++++++++++++++------ 1 file changed, 87 insertions(+), 28 deletions(-) diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 3ff059a28..9b2722d7d 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -6,11 +6,17 @@ import { orgs, Resource, resources, + resourcePolicies, roleResources, + rolePolicies, roles, + userPolicies, userResources } from "@server/db"; -import { getUniqueResourceName } from "@server/db/names"; +import { + getUniqueResourceName, + getUniqueResourcePolicyName +} from "@server/db/names"; import config from "@server/lib/config"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; import response from "@server/lib/response"; @@ -241,8 +247,46 @@ async function createHttpResource( let resource: Resource | undefined; const niceId = await getUniqueResourceName(orgId); + const policyNiceId = await getUniqueResourcePolicyName(orgId); await db.transaction(async (trx) => { + const adminRole = await trx + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (adminRole.length === 0) { + return next( + createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) + ); + } + + const [defaultPolicy] = await trx + .insert(resourcePolicies) + .values({ + niceId: policyNiceId, + orgId, + name: `default policy for ${niceId}`, + sso: true, + scope: "resource" + }) + .returning(); + + // make this policy visible by the admin role + await trx.insert(rolePolicies).values({ + roleId: adminRole[0].roleId, + resourcePolicyId: defaultPolicy.resourcePolicyId + }); + + // make this policy visible by the current user + if (req.user && req.userOrgRoleId !== adminRole[0].roleId) { + await trx.insert(userPolicies).values({ + userId: req.user?.userId!, + resourcePolicyId: defaultPolicy.resourcePolicyId + }); + } + const newResource = await trx .insert(resources) .values({ @@ -256,22 +300,11 @@ async function createHttpResource( protocol: "tcp", ssl: true, stickySession: stickySession, - postAuthPath: postAuthPath + postAuthPath: postAuthPath, + defaultResourcePolicyId: defaultPolicy.resourcePolicyId }) .returning(); - const adminRole = await db - .select() - .from(roles) - .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) - .limit(1); - - if (adminRole.length === 0) { - return next( - createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) - ); - } - await trx.insert(roleResources).values({ roleId: adminRole[0].roleId, resourceId: newResource[0].resourceId @@ -338,22 +371,10 @@ async function createRawResource( let resource: Resource | undefined; const niceId = await getUniqueResourceName(orgId); + const policyNiceId = await getUniqueResourcePolicyName(orgId); await db.transaction(async (trx) => { - const newResource = await trx - .insert(resources) - .values({ - niceId, - orgId, - name, - http, - protocol, - proxyPort - // enableProxy - }) - .returning(); - - const adminRole = await db + const adminRole = await trx .select() .from(roles) .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) @@ -365,6 +386,44 @@ async function createRawResource( ); } + const [defaultPolicy] = await trx + .insert(resourcePolicies) + .values({ + niceId: policyNiceId, + orgId, + name: `default policy for ${niceId}`, + sso: true, + scope: "resource" + }) + .returning(); + + // make this policy visible by the admin role + await trx.insert(rolePolicies).values({ + roleId: adminRole[0].roleId, + resourcePolicyId: defaultPolicy.resourcePolicyId + }); + + // make this policy visible by the current user + if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + await trx.insert(userPolicies).values({ + userId: req.user?.userId!, + resourcePolicyId: defaultPolicy.resourcePolicyId + }); + } + + const newResource = await trx + .insert(resources) + .values({ + niceId, + orgId, + name, + http, + protocol, + proxyPort, + defaultResourcePolicyId: defaultPolicy.resourcePolicyId + }) + .returning(); + await trx.insert(roleResources).values({ roleId: adminRole[0].roleId, resourceId: newResource[0].resourceId From 4de4bf9625c8110abbc3a108e305ac0bbd1bd531 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 7 Mar 2026 03:35:26 +0100 Subject: [PATCH 69/89] =?UTF-8?q?=E2=9C=A8=20use=20resource=20policies=20f?= =?UTF-8?q?or=20auth=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/resource/listResources.ts | 96 ++++++++++++++---------- 1 file changed, 56 insertions(+), 40 deletions(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index f9dd14e98..4becfb579 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -1,9 +1,9 @@ import { db, - resourceHeaderAuth, - resourceHeaderAuthExtendedCompatibility, - resourcePassword, - resourcePincode, + resourcePolicies, + resourcePolicyHeaderAuth, + resourcePolicyPassword, + resourcePolicyPincode, resources, roleResources, targetHealthCheck, @@ -169,38 +169,54 @@ function queryResourcesBase() { name: resources.name, ssl: resources.ssl, fullDomain: resources.fullDomain, - passwordId: resourcePassword.passwordId, - sso: resources.sso, - pincodeId: resourcePincode.pincodeId, - whitelist: resources.emailWhitelistEnabled, + passwordId: resourcePolicyPassword.passwordId, + sso: resourcePolicies.sso, + pincodeId: resourcePolicyPincode.pincodeId, + whitelist: resourcePolicies.emailWhitelistEnabled, http: resources.http, protocol: resources.protocol, proxyPort: resources.proxyPort, enabled: resources.enabled, domainId: resources.domainId, niceId: resources.niceId, - headerAuthId: resourceHeaderAuth.headerAuthId, - headerAuthExtendedCompatibilityId: - resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId + headerAuthId: resourcePolicyHeaderAuth.headerAuthId, + headerAuthExtendedCompatibility: + resourcePolicyHeaderAuth.extendedCompatibility }) .from(resources) .leftJoin( - resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId) + resourcePolicies, + or( + eq( + resourcePolicies.resourcePolicyId, + resources.resourcePolicyId + ), + eq( + resourcePolicies.resourcePolicyId, + resources.defaultResourcePolicyId + ) + ) ) + .leftJoin( - resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId) - ) - .leftJoin( - resourceHeaderAuth, - eq(resourceHeaderAuth.resourceId, resources.resourceId) - ) - .leftJoin( - resourceHeaderAuthExtendedCompatibility, + resourcePolicyPassword, eq( - resourceHeaderAuthExtendedCompatibility.resourceId, - resources.resourceId + resourcePolicyPassword.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) + .leftJoin( + resourcePolicyPincode, + eq( + resourcePolicyPincode.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) + .leftJoin( + resourcePolicyHeaderAuth, + eq( + resourcePolicyHeaderAuth.resourcePolicyId, + resourcePolicies.resourcePolicyId ) ) .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) @@ -210,10 +226,10 @@ function queryResourcesBase() { ) .groupBy( resources.resourceId, - resourcePassword.passwordId, - resourcePincode.pincodeId, - resourceHeaderAuth.headerAuthId, - resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId + resourcePolicies.resourcePolicyId, + resourcePolicyPassword.passwordId, + resourcePolicyPincode.pincodeId, + resourcePolicyHeaderAuth.headerAuthId ); } @@ -358,21 +374,21 @@ export async function listResources( case "protected": conditions.push( or( - eq(resources.sso, true), - eq(resources.emailWhitelistEnabled, true), - not(isNull(resourceHeaderAuth.headerAuthId)), - not(isNull(resourcePincode.pincodeId)), - not(isNull(resourcePassword.passwordId)) + eq(resourcePolicies.sso, true), + eq(resourcePolicies.emailWhitelistEnabled, true), + not(isNull(resourcePolicyHeaderAuth.headerAuthId)), + not(isNull(resourcePolicyPincode.pincodeId)), + not(isNull(resourcePolicyPassword.passwordId)) ) ); break; case "not_protected": conditions.push( - not(eq(resources.sso, true)), - not(eq(resources.emailWhitelistEnabled, true)), - isNull(resourceHeaderAuth.headerAuthId), - isNull(resourcePincode.pincodeId), - isNull(resourcePassword.passwordId) + not(eq(resourcePolicies.sso, true)), + not(eq(resourcePolicies.emailWhitelistEnabled, true)), + isNull(resourcePolicyHeaderAuth.headerAuthId), + isNull(resourcePolicyPincode.pincodeId), + isNull(resourcePolicyPassword.passwordId) ); break; } @@ -468,9 +484,9 @@ export async function listResources( ssl: row.ssl, fullDomain: row.fullDomain, passwordId: row.passwordId, - sso: row.sso, + sso: row.sso ?? false, pincodeId: row.pincodeId, - whitelist: row.whitelist, + whitelist: row.whitelist ?? false, http: row.http, protocol: row.protocol, proxyPort: row.proxyPort, From c5f6d822cabd4c9b458bca1d1409484a81066878 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 7 Mar 2026 03:45:10 +0100 Subject: [PATCH 70/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor=20auth=20in?= =?UTF-8?q?fo=20to=20use=20resource=20policies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routers/resource/getResourceAuthInfo.ts | 126 ++++++++---------- 1 file changed, 57 insertions(+), 69 deletions(-) diff --git a/server/routers/resource/getResourceAuthInfo.ts b/server/routers/resource/getResourceAuthInfo.ts index 7def75d5b..2f8b10e0f 100644 --- a/server/routers/resource/getResourceAuthInfo.ts +++ b/server/routers/resource/getResourceAuthInfo.ts @@ -2,13 +2,13 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, - resourceHeaderAuth, - resourceHeaderAuthExtendedCompatibility, - resourcePassword, - resourcePincode, + resourcePolicies, + resourcePolicyHeaderAuth, + resourcePolicyPassword, + resourcePolicyPincode, resources } from "@server/db"; -import { eq } from "drizzle-orm"; +import { eq, or } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -58,64 +58,53 @@ export async function getResourceAuthInfo( const isGuidInteger = /^\d+$/.test(resourceGuid); + const buildQuery = (whereClause: ReturnType) => + db + .select() + .from(resources) + .leftJoin( + resourcePolicies, + or( + eq( + resourcePolicies.resourcePolicyId, + resources.resourcePolicyId + ), + eq( + resourcePolicies.resourcePolicyId, + resources.defaultResourcePolicyId + ) + ) + ) + .leftJoin( + resourcePolicyPincode, + eq( + resourcePolicyPincode.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) + .leftJoin( + resourcePolicyPassword, + eq( + resourcePolicyPassword.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) + .leftJoin( + resourcePolicyHeaderAuth, + eq( + resourcePolicyHeaderAuth.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) + .where(whereClause) + .limit(1); + const [result] = isGuidInteger && build === "saas" - ? await db - .select() - .from(resources) - .leftJoin( - resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId) - ) - .leftJoin( - resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId) - ) - - .leftJoin( - resourceHeaderAuth, - eq( - resourceHeaderAuth.resourceId, - resources.resourceId - ) - ) - .leftJoin( - resourceHeaderAuthExtendedCompatibility, - eq( - resourceHeaderAuthExtendedCompatibility.resourceId, - resources.resourceId - ) - ) - .where(eq(resources.resourceId, Number(resourceGuid))) - .limit(1) - : await db - .select() - .from(resources) - .leftJoin( - resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId) - ) - .leftJoin( - resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId) - ) - - .leftJoin( - resourceHeaderAuth, - eq( - resourceHeaderAuth.resourceId, - resources.resourceId - ) - ) - .leftJoin( - resourceHeaderAuthExtendedCompatibility, - eq( - resourceHeaderAuthExtendedCompatibility.resourceId, - resources.resourceId - ) - ) - .where(eq(resources.resourceGuid, resourceGuid)) - .limit(1); + ? await buildQuery( + eq(resources.resourceId, Number(resourceGuid)) + ) + : await buildQuery(eq(resources.resourceGuid, resourceGuid)); const resource = result?.resources; if (!resource) { @@ -124,11 +113,10 @@ export async function getResourceAuthInfo( ); } - const pincode = result?.resourcePincode; - const password = result?.resourcePassword; - const headerAuth = result?.resourceHeaderAuth; - const headerAuthExtendedCompatibility = - result?.resourceHeaderAuthExtendedCompatibility; + const policy = result?.resourcePolicies; + const pincode = result?.resourcePolicyPincode; + const password = result?.resourcePolicyPassword; + const headerAuth = result?.resourcePolicyHeaderAuth; const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; @@ -142,11 +130,11 @@ export async function getResourceAuthInfo( pincode: pincode !== null, headerAuth: headerAuth !== null, headerAuthExtendedCompatibility: - headerAuthExtendedCompatibility !== null, - sso: resource.sso, + headerAuth?.extendedCompatibility ?? false, + sso: policy?.sso ?? false, blockAccess: resource.blockAccess, url, - whitelist: resource.emailWhitelistEnabled, + whitelist: policy?.emailWhitelistEnabled ?? false, skipToIdpId: resource.skipToIdpId, orgId: resource.orgId, postAuthPath: resource.postAuthPath ?? null From 2fa1bc6cdcabf71c4bfb37349939a5224dc55e8b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 7 Mar 2026 03:55:30 +0100 Subject: [PATCH 71/89] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[orgId]/settings/resources/proxy/[niceId]/layout.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx index f410b4c8b..d2432ae1a 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx @@ -91,10 +91,10 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { title: t("authentication"), href: `/{orgId}/settings/resources/proxy/{niceId}/authentication` }); - navItems.push({ - title: t("rules"), - href: `/{orgId}/settings/resources/proxy/{niceId}/rules` - }); + // navItems.push({ + // title: t("rules"), + // href: `/{orgId}/settings/resources/proxy/{niceId}/rules` + // }); } return ( From 79636cbb3052c0deec193b10f4d8f8d50655b31b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 10 Mar 2026 17:38:19 +0100 Subject: [PATCH 72/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20delete=20default=20r?= =?UTF-8?q?esource=20policy=20ID=20when=20deleting=20a=20resource?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/resource/deleteResource.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index f69853a90..da673a944 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -1,4 +1,4 @@ -import { db, resources, targets } from "@server/db"; +import { db, resourcePolicies, resources, targets } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -62,6 +62,18 @@ export async function deleteResource( ); } + // Also delete default resource policy + if (deletedResource.defaultResourcePolicyId) { + await db + .delete(resourcePolicies) + .where( + eq( + resourcePolicies.resourcePolicyId, + deletedResource.defaultResourcePolicyId + ) + ); + } + // const [site] = await db // .select() // .from(sites) From 6686de67881c2e56aa945268bf28128d31296345 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 10 Mar 2026 17:48:17 +0100 Subject: [PATCH 73/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/resources/proxy/create/page.tsx | 21 ++++++++----- src/components/ProxyResourcesTable.tsx | 31 +++++++++---------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx index ff51a311b..3374d71b4 100644 --- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx @@ -89,7 +89,13 @@ import { useTranslations } from "next-intl"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { toASCII } from "punycode"; -import { useEffect, useMemo, useState, useCallback } from "react"; +import { + useMemo, + useState, + useCallback, + useTransition, + useEffect +} from "react"; import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; @@ -210,7 +216,7 @@ export default function Page() { orgQueries.sites({ orgId: orgId as string }) ); - const [createLoading, setCreateLoading] = useState(false); + const [createLoading, startTransition] = useTransition(); const [showSnippets, setShowSnippets] = useState(false); const [niceId, setNiceId] = useState(""); @@ -293,7 +299,10 @@ export default function Page() { { id: "raw" as ResourceType, title: t("resourceRaw"), - description: build == "saas" ? t("resourceRawDescriptionCloud") : t("resourceRawDescription") + description: + build == "saas" + ? t("resourceRawDescriptionCloud") + : t("resourceRawDescription") } ]) ]; @@ -432,8 +441,6 @@ export default function Page() { ); async function onSubmit() { - setCreateLoading(true); - const baseData = baseForm.getValues(); const isHttp = baseData.http; @@ -562,8 +569,6 @@ export default function Page() { description: t("resourceErrorCreateMessageDescription") }); } - - setCreateLoading(false); } useEffect(() => { @@ -1394,7 +1399,7 @@ export default function Page() { console.log(httpForm.getValues()); if (baseValid && settingsValid) { - onSubmit(); + startTransition(onSubmit); } }} loading={createLoading} diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 353eddb50..b3db7a750 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -174,22 +174,17 @@ export default function ProxyResourcesTable({ }); }; - const deleteResource = (resourceId: number) => { - api.delete(`/resource/${resourceId}`) - .catch((e) => { - console.error(t("resourceErrorDelte"), e); - toast({ - variant: "destructive", - title: t("resourceErrorDelte"), - description: formatAxiosError(e, t("resourceErrorDelte")) - }); - }) - .then(() => { - startTransition(() => { - router.refresh(); - setIsDeleteModalOpen(false); - }); + const deleteResource = async (resourceId: number) => { + await api.delete(`/resource/${resourceId}`).catch((e) => { + console.error(t("resourceErrorDelte"), e); + toast({ + variant: "destructive", + title: t("resourceErrorDelte"), + description: formatAxiosError(e, t("resourceErrorDelte")) }); + }); + router.refresh(); + setIsDeleteModalOpen(false); }; async function toggleResourceEnabled(val: boolean, resourceId: number) { @@ -626,7 +621,11 @@ export default function ProxyResourcesTable({
} buttonText={t("resourceDeleteConfirm")} - onConfirm={async () => deleteResource(selectedResource!.id)} + onConfirm={async () => + startTransition(() => + deleteResource(selectedResource!.id) + ) + } string={selectedResource.name} title={t("resourceDelete")} /> From 61ec938b0012404b70a912eb08bbbd671fdf6084 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 10 Mar 2026 18:54:26 +0100 Subject: [PATCH 74/89] =?UTF-8?q?=F0=9F=9A=A7=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 6 + server/auth/actions.ts | 3 +- server/routers/external.ts | 7 + server/routers/integration.ts | 7 + server/routers/policy/getResourcePolicy.ts | 8 +- .../routers/resource/getResourcePolicies.ts | 88 +++++ server/routers/resource/index.ts | 1 + solo.yml | 3 + .../proxy/[niceId]/authentication/page.tsx | 352 +++--------------- src/components/StrategySelect.tsx | 16 +- src/lib/queries.ts | 12 + 11 files changed, 191 insertions(+), 312 deletions(-) create mode 100644 server/routers/resource/getResourcePolicies.ts create mode 100644 solo.yml diff --git a/messages/en-US.json b/messages/en-US.json index a6d2d36c4..106fc400a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -766,6 +766,12 @@ "resourcePincodeSetupTitle": "Set Pincode", "resourcePincodeSetupTitleDescription": "Set a pincode to protect this resource", "resourceRoleDescription": "Admins can always access this resource.", + "resourcePolicySelectTitle": "Resource Access Policy", + "resourcePolicySelectDescription": "Select the resource policy type for authentication", + "resourcePolicyInline": "Inline Resource Policy", + "resourcePolicyInlineDescription": "Access Policy scoped to only this resource", + "resourcePolicyShared": "Shared Resource Policy", + "resourcePolicySharedDescription": "Access Policy shared accross multiple resources", "resourceUsersRoles": "Access Controls", "resourceUsersRolesDescription": "Configure which users and roles can visit this resource", "resourceUsersRolesSubmit": "Save Access Controls", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index b34b3fe57..5549380ab 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -146,7 +146,8 @@ export enum ActionsEnum { setResourcePolicyPincode = "setResourcePolicyPincode", setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth", setResourcePolicyWhitelist = "setResourcePolicyWhitelist", - setResourcePolicyRules = "setResourcePolicyRules" + setResourcePolicyRules = "setResourcePolicyRules", + getResourcePolicies = "getResourcePolicies" } export async function checkUserActionPermission( diff --git a/server/routers/external.ts b/server/routers/external.ts index 671bd4ac7..130ebbbb4 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -636,6 +636,13 @@ authenticated.get( policy.getResourcePolicy ); +authenticated.get( + "/resource/:resourceId/policies", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.getResourcePolicies), + resource.getResourcePolicies +); + authenticated.put( "/resource-policy/:resourcePolicyId", verifyResourcePolicyAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 92f1531ee..cadda13cb 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -453,6 +453,13 @@ authenticated.get( policy.getResourcePolicy ); +authenticated.get( + "/resource/:resourceId/policies", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.getResourcePolicies), + resource.getResourcePolicies +); + authenticated.post( "/resource/:resourceId", verifyApiKeyResourceAccess, diff --git a/server/routers/policy/getResourcePolicy.ts b/server/routers/policy/getResourcePolicy.ts index 2a975dde7..d7513d58d 100644 --- a/server/routers/policy/getResourcePolicy.ts +++ b/server/routers/policy/getResourcePolicy.ts @@ -40,7 +40,9 @@ const getResourcePolicySchema = z }) ); -async function query(params: z.infer) { +export async function queryResourcePolicy( + params: z.infer +) { const conditions: SQL[] = []; if ("resourcePolicyId" in params) { conditions.push( @@ -158,7 +160,7 @@ async function query(params: z.infer) { } export type GetResourcePolicyResponse = NonNullable< - Awaited> + Awaited> >; registry.registerPath({ @@ -205,7 +207,7 @@ export async function getResourcePolicy( ); } - const policy = await query(parsedParams.data); + const policy = await queryResourcePolicy(parsedParams.data); if (!policy) { return next( diff --git a/server/routers/resource/getResourcePolicies.ts b/server/routers/resource/getResourcePolicies.ts new file mode 100644 index 000000000..c51e6f1fd --- /dev/null +++ b/server/routers/resource/getResourcePolicies.ts @@ -0,0 +1,88 @@ +import { db, resources } from "@server/db"; +import { + queryResourcePolicy, + type GetResourcePolicyResponse +} from "@server/routers/policy/getResourcePolicy"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import { eq } from "drizzle-orm"; +import type { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import z from "zod"; +import { fromError } from "zod-validation-error"; + +const getResourcePoliciesParamsSchema = z.strictObject({ + resourceId: z.string().transform(Number).pipe(z.int().positive()) +}); + +export type GetResourcePoliciesResponse = { + defaultPolicy: GetResourcePolicyResponse | null; +}; + +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/policies", + description: "Get the default policy for a resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.Policy], + request: { + params: getResourcePoliciesParamsSchema + }, + responses: {} +}); + +export async function getResourcePolicies( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getResourcePoliciesParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const [resource] = await db + .select({ + defaultResourcePolicyId: resources.defaultResourcePolicyId + }) + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const defaultPolicy = resource.defaultResourcePolicyId + ? await queryResourcePolicy({ + resourcePolicyId: resource.defaultResourcePolicyId + }) + : null; + + return response(res, { + data: { defaultPolicy }, + success: true, + error: false, + message: "Resource policies retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 3ada13d85..78803105f 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -31,3 +31,4 @@ export * from "./addUserToResource"; export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; +export * from "./getResourcePolicies"; diff --git a/solo.yml b/solo.yml new file mode 100644 index 000000000..1a6f9f331 --- /dev/null +++ b/solo.yml @@ -0,0 +1,3 @@ +name: pangolin +icon: public/logo/pangolin_profile_picture.png +processes: {} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index a533fb6c3..e2e315d35 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -12,6 +12,10 @@ import { SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; +import { + StrategySelect, + type StrategyOption +} from "@app/components/StrategySelect"; import { SwitchInput } from "@app/components/SwitchInput"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; @@ -42,6 +46,7 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { orgQueries, resourceQueries } from "@app/lib/queries"; +import { ResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; import { zodResolver } from "@hookform/resolvers/zod"; import { build } from "@server/build"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; @@ -86,6 +91,8 @@ const whitelistSchema = z.object({ ) }); +type ResourcePolicyType = StrategyOption<"inline" | "shared">; + export default function ResourceAuthenticationPage() { const { org } = useOrgContext(); const { resource, updateResource, authInfo, updateAuthInfo } = @@ -118,6 +125,11 @@ export default function ResourceAuthenticationPage() { resourceId: resource.resourceId }) ); + const { data: policies, isLoading: isLoadingPolicies } = useQuery( + resourceQueries.policies({ + resourceId: resource.resourceId + }) + ); const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery( orgQueries.roles({ @@ -142,7 +154,8 @@ export default function ResourceAuthenticationPage() { isLoadingResourceRoles || isLoadingResourceUsers || isLoadingWhiteList || - isLoadingOrgIdps; + isLoadingOrgIdps || + isLoadingPolicies; const allRoles = useMemo(() => { return orgRoles @@ -413,6 +426,22 @@ export default function ResourceAuthenticationPage() { .finally(() => setLoadingRemoveResourceHeaderAuth(false)); } + const resourcePolicyTypes: Array = [ + { + id: "inline", + title: t("resourcePolicyInline"), + description: t("resourcePolicyInlineDescription") + }, + { + id: "shared", + title: t("resourcePolicyShared"), + description: t("resourcePolicySharedDescription") + } + ]; + + const [selectedResourceType, setSelectedResourceType] = + useState("inline"); + if (pageLoading) { return <>; } @@ -465,324 +494,39 @@ export default function ResourceAuthenticationPage() { - {t("resourceUsersRoles")} + {t("resourcePolicySelectTitle")} - {t("resourceUsersRolesDescription")} + {t("resourcePolicySelectDescription")} - - setSsoEnabled(val)} - /> - -
- - {ssoEnabled && ( - <> - ( - - - {t("roles")} - - - { - usersRolesForm.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allRoles - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - {t( - "resourceRoleDescription" - )} - - - )} - /> - ( - - - {t("users")} - - - { - usersRolesForm.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allUsers - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - - )} - - {ssoEnabled && allIdps.length > 0 && ( -
- - -

- {t( - "defaultIdentityProviderDescription" - )} -

-
- )} - - -
+ { + // baseForm.setValue( + // "http", + // value === "http" + // ); + // // Update method default when switching resource type + }} + cols={2} + />
+ {/* - - - - {t("resourceAuthMethods")} - - - {t("resourceAuthMethodsDescriptions")} - - - - - {/* Password Protection */} -
-
- - - {t("resourcePasswordProtection", { - status: authInfo.password - ? t("enabled") - : t("disabled") - })} - -
- -
- - {/* PIN Code Protection */} -
-
- - - {t("resourcePincodeProtection", { - status: authInfo.pincode - ? t("enabled") - : t("disabled") - })} - -
- -
- - {/* Header Authentication Protection */} -
-
- - - {authInfo.headerAuth - ? t( - "resourceHeaderAuthProtectionEnabled" - ) - : t( - "resourceHeaderAuthProtectionDisabled" - )} - -
- -
-
-
-
- - +
*/} ); diff --git a/src/components/StrategySelect.tsx b/src/components/StrategySelect.tsx index 7f747360f..b4cd961d4 100644 --- a/src/components/StrategySelect.tsx +++ b/src/components/StrategySelect.tsx @@ -25,11 +25,15 @@ export function StrategySelect({ value: controlledValue, defaultValue, onChange, - cols + cols = 1 }: StrategySelectProps) { - const [uncontrolledSelected, setUncontrolledSelected] = useState(defaultValue); + const [uncontrolledSelected, setUncontrolledSelected] = useState< + TValue | undefined + >(defaultValue); const isControlled = controlledValue !== undefined; - const selected = isControlled ? (controlledValue ?? undefined) : uncontrolledSelected; + const selected = isControlled + ? (controlledValue ?? undefined) + : uncontrolledSelected; return ( ({ if (!isControlled) setUncontrolledSelected(typedValue); onChange?.(typedValue); }} - className={`grid md:grid-cols-${cols ? cols : 1} gap-4`} + style={{ + // @ts-expect-error + "--cols": `repeat(${cols}, 1fr)` + }} + className="grid md:grid-cols-(--cols) gap-4" > {options.map((option: StrategyOption) => (
@@ -741,6 +757,7 @@ export function EditPolicyRulesSectionForm({ ) : ( - + )} @@ -1053,7 +1073,7 @@ export function EditPolicyRulesSectionForm({ @@ -1134,7 +1154,7 @@ export function EditPolicyRulesSectionForm({ diff --git a/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx b/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx index d4d9b2de2..29fe486be 100644 --- a/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx @@ -53,12 +53,14 @@ type PolicyUsersRolesSectionProps = { allRoles: { id: string; text: string }[]; allUsers: { id: string; text: string }[]; allIdps: { id: number; text: string }[]; + readonly?: boolean; }; export function EditPolicyUsersRolesSectionForm({ allRoles, allUsers, - allIdps + allIdps, + readonly }: PolicyUsersRolesSectionProps) { const t = useTranslations(); @@ -106,6 +108,8 @@ export function EditPolicyUsersRolesSectionForm({ const [, formAction, isSubmitting] = useActionState(onSubmit, null); async function onSubmit() { + if (readonly) return; + const isValid = await form.trigger(); if (!isValid) return; @@ -172,6 +176,7 @@ export function EditPolicyUsersRolesSectionForm({ console.log(`form.setValue("sso", ${val})`); form.setValue("sso", val); }} + disabled={readonly} /> {ssoEnabled && ( @@ -221,6 +226,7 @@ export function EditPolicyUsersRolesSectionForm({ true } sortTags={true} + disabled={readonly} /> @@ -277,6 +283,7 @@ export function EditPolicyUsersRolesSectionForm({ true } sortTags={true} + disabled={readonly} /> @@ -292,6 +299,7 @@ export function EditPolicyUsersRolesSectionForm({ {t("defaultIdentityProvider")} + )} From b286096c7b23faa262f7bb0f5ffe92c21e050fcc Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 11 Mar 2026 03:47:31 +0100 Subject: [PATCH 79/89] =?UTF-8?q?=F0=9F=8C=90=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/messages/en-US.json b/messages/en-US.json index 106fc400a..7bd6b9c51 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -731,6 +731,11 @@ "pincodeAdd": "Add PIN Code", "pincodeRemove": "Remove PIN Code", "resourceAuthMethods": "Authentication Methods", + "resourcePolicyAuthMethodsEmpty": "No authentication method", + "resourcePolicyOtpEmpty": "No one time password", + "resourcePolicyTypeSave": "Save Resource type", + "resourcePolicySelect": "Select resource policy", + "resourcePolicyRulesEmpty": "No authentication rules", "resourceAuthMethodsDescriptions": "Allow access to the resource via additional auth methods", "resourceAuthSettingsSave": "Saved successfully", "resourceAuthSettingsSaveDescription": "Authentication settings have been saved", From 304ab1964cfd3c1dab4edfbc0263da3077b68675 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 11 Mar 2026 04:21:55 +0100 Subject: [PATCH 80/89] =?UTF-8?q?=F0=9F=9A=A7=20=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../proxy/[niceId]/authentication/page.tsx | 202 ++++++++++++------ src/components/Settings.tsx | 11 +- src/lib/queries.ts | 38 +++- 3 files changed, 184 insertions(+), 67 deletions(-) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index 9609d9da1..414133c86 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -21,6 +21,14 @@ import { SwitchInput } from "@app/components/SwitchInput"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; import { Form, FormControl, @@ -31,6 +39,11 @@ import { FormMessage } from "@app/components/ui/form"; import { InfoPopup } from "@app/components/ui/info-popup"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; import { Select, SelectContent, @@ -45,19 +58,25 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { orgQueries, resourceQueries } from "@app/lib/queries"; +import { + orgQueries, + resourcePolicyQueries, + resourceQueries +} from "@app/lib/queries"; import { ResourcePolicyContext, ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider"; import { zodResolver } from "@hookform/resolvers/zod"; +import { CaretSortIcon } from "@radix-ui/react-icons"; import { build } from "@server/build"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { UserType } from "@server/types/UserTypes"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import SetResourcePasswordForm from "components/SetResourcePasswordForm"; -import { Binary, Bot, InfoIcon, Key } from "lucide-react"; +import { Binary, Bot, CheckIcon, InfoIcon, Key } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { @@ -103,6 +122,41 @@ export default function ResourceAuthenticationPage() { }) ); + const form = useForm({ + resolver: zodResolver(resourceTypeSchema), + defaultValues: { + type: "inline" + } + }); + + const selectedResourceType = useWatch({ + control: form.control, + name: "type" + }); + + const [resourcePolicysearchQuery, setResourcePolicySearchQuery] = + useState(""); + + const { data: policiesList = [] } = useQuery({ + ...orgQueries.policies({ + orgId: org.org.orgId, + name: resourcePolicysearchQuery + }), + enabled: selectedResourceType === "shared" + }); + + const { data: sharedPolicy } = useQuery({ + ...resourcePolicyQueries.single({ + resourcePolicyId: resource.resourcePolicyId ?? 1 + }), + enabled: !!resource.resourcePolicyId + }); + + const [selectedPolicy, setSelectedPolicy] = useState<{ + name: string; + id: number; + } | null>(null); + const pageLoading = isLoadingPolicies || !defaultPolicy; const [ @@ -127,66 +181,12 @@ export default function ResourceAuthenticationPage() { } ]; - const form = useForm({ - resolver: zodResolver(resourceTypeSchema), - defaultValues: { - type: "inline" - } - }); - - const selectedResourceType = useWatch({ - control: form.control, - name: "type" - }); - if (pageLoading) { return <>; } return ( <> - {isSetPasswordOpen && ( - { - setIsSetPasswordOpen(false); - updateAuthInfo({ - password: true - }); - }} - /> - )} - - {isSetPincodeOpen && ( - { - setIsSetPincodeOpen(false); - updateAuthInfo({ - pincode: true - }); - }} - /> - )} - - {isSetHeaderAuthOpen && ( - { - setIsSetHeaderAuthOpen(false); - updateAuthInfo({ - headerAuth: true - }); - }} - /> - )} - @@ -206,20 +206,94 @@ export default function ResourceAuthenticationPage() { }} cols={2} /> + {selectedResourceType === "shared" && ( + + + + + + + + + + {t("siteNotFound")} + + + {policiesList.map((policy) => ( + + setSelectedPolicy({ + id: policy.resourcePolicyId, + name: policy.name + }) + } + > + + {policy.name} + + ))} + + + + + + )} - + - - - + {selectedResourceType === "inline" ? ( + + + + ) : ( + sharedPolicy && ( + + + + ) + )} ); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 194d8b467..f53ddf9d0 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -61,12 +61,19 @@ export function SettingsSectionBody({ } export function SettingsSectionFooter({ - children + children, + className }: { children: React.ReactNode; + className?: string; }) { return ( -
+
{children}
); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 8f5ccab1c..022239006 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -30,6 +30,7 @@ import z from "zod"; import { remote } from "./api"; import { durationToMs } from "./durationToMs"; import { wait } from "./wait"; +import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; export type ProductUpdate = { link: string | null; @@ -196,6 +197,41 @@ export const orgQueries = { return res.data.data.resources; } + }), + policies: ({ orgId, name }: { orgId: string; name?: string }) => + queryOptions({ + queryKey: ["ORG", orgId, "RESOURCES_POLICIES", name] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: "10" + }); + + if (name) { + sp.set("query", name); + } + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/resource-policies?${sp.toString()}`, { + signal + }); + + return res.data.data.policies; + } + }) +}; + +export const resourcePolicyQueries = { + single: ({ resourcePolicyId }: { resourcePolicyId: number }) => + queryOptions({ + queryKey: ["RESOURCE_POLICIES", resourcePolicyId] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/resource-policy/${resourcePolicyId}`, { signal }); + + return res.data.data; + } }) }; @@ -325,7 +361,7 @@ export const resourceQueries = { }), defaultPolicy: ({ resourceId }: { resourceId: number }) => queryOptions({ - queryKey: ["RESOURCES", resourceId, "POLICIES"] as const, + queryKey: ["RESOURCES", resourceId, "DEFAULT_POLICY"] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse From 36bcba332c30f9711e483381539df2c97b305c1a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 11 Mar 2026 05:18:22 +0100 Subject: [PATCH 81/89] =?UTF-8?q?=F0=9F=9A=A7=20=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + server/routers/external.ts | 4 +- server/routers/integration.ts | 4 +- ...sourcePolicy.ts => getResourcePolicies.ts} | 37 ++++++--- server/routers/resource/index.ts | 2 +- .../proxy/[niceId]/authentication/page.tsx | 75 +++++++++++++------ src/lib/queries.ts | 9 ++- 7 files changed, 90 insertions(+), 42 deletions(-) rename server/routers/resource/{getDefaultResourcePolicy.ts => getResourcePolicies.ts} (68%) diff --git a/messages/en-US.json b/messages/en-US.json index 7bd6b9c51..efaf777b5 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -735,6 +735,7 @@ "resourcePolicyOtpEmpty": "No one time password", "resourcePolicyTypeSave": "Save Resource type", "resourcePolicySelect": "Select resource policy", + "resourcePolicyNotFound": "Policy not found", "resourcePolicyRulesEmpty": "No authentication rules", "resourceAuthMethodsDescriptions": "Allow access to the resource via additional auth methods", "resourceAuthSettingsSave": "Saved successfully", diff --git a/server/routers/external.ts b/server/routers/external.ts index a23ec54ad..9ca80dc34 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -637,10 +637,10 @@ authenticated.get( ); authenticated.get( - "/resource/:resourceId/default-policy", + "/resource/:resourceId/policies", verifyResourceAccess, verifyUserHasAction(ActionsEnum.getResourcePolicy), - resource.getDefaultResourcePolicy + resource.getResourcePolicies ); authenticated.put( diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 56dec9dee..6b682bff7 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -454,10 +454,10 @@ authenticated.get( ); authenticated.get( - "/resource/:resourceId/default-policy", + "/resource/:resourceId/policies", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.getResourcePolicy), - resource.getDefaultResourcePolicy + resource.getResourcePolicies ); authenticated.post( diff --git a/server/routers/resource/getDefaultResourcePolicy.ts b/server/routers/resource/getResourcePolicies.ts similarity index 68% rename from server/routers/resource/getDefaultResourcePolicy.ts rename to server/routers/resource/getResourcePolicies.ts index 39621c2ea..6742a0bd5 100644 --- a/server/routers/resource/getDefaultResourcePolicy.ts +++ b/server/routers/resource/getResourcePolicies.ts @@ -17,12 +17,15 @@ const getResourcePoliciesParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); -export type GetDefaultResourcePolicyResponse = GetResourcePolicyResponse; +export type GetResourcePoliciesResponse = { + defaultPolicy: GetResourcePolicyResponse; + sharedPolicy: GetResourcePolicyResponse | null; +}; registry.registerPath({ method: "get", - path: "/resource/{resourceId}/default-policy", - description: "Get the default policy for a resource.", + path: "/resource/{resourceId}/policies", + description: "Get the inline and shared policies associated with a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Policy], request: { params: getResourcePoliciesParamsSchema @@ -30,7 +33,7 @@ registry.registerPath({ responses: {} }); -export async function getDefaultResourcePolicy( +export async function getResourcePolicies( req: Request, res: Response, next: NextFunction @@ -52,7 +55,8 @@ export async function getDefaultResourcePolicy( const [resource] = await db .select({ - defaultResourcePolicyId: resources.defaultResourcePolicyId + defaultResourcePolicyId: resources.defaultResourcePolicyId, + resourcePolicyId: resources.resourcePolicyId }) .from(resources) .where(eq(resources.resourceId, resourceId)) @@ -73,11 +77,24 @@ export async function getDefaultResourcePolicy( ); } - const defaultPolicy = await queryResourcePolicy({ - resourcePolicyId: resource.defaultResourcePolicyId - }); - return response(res, { - data: defaultPolicy, + const [defaultPolicy, sharedPolicy] = await Promise.all([ + queryResourcePolicy({ + resourcePolicyId: resource.defaultResourcePolicyId + }), + resource.resourcePolicyId + ? queryResourcePolicy({ + resourcePolicyId: resource.resourcePolicyId + }) + : null + ]); + + return response(res, { + data: { + defaultPolicy: + // the policy will always be non nullable + defaultPolicy as unknown as GetResourcePolicyResponse, + sharedPolicy + }, success: true, error: false, message: "Resource policies retrieved successfully", diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index e602932f0..78803105f 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -31,4 +31,4 @@ export * from "./addUserToResource"; export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; -export * from "./getDefaultResourcePolicy"; +export * from "./getResourcePolicies"; diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index 414133c86..c1eadf5f3 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -79,7 +79,7 @@ import SetResourcePasswordForm from "components/SetResourcePasswordForm"; import { Binary, Bot, CheckIcon, InfoIcon, Key } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { +import React, { useActionState, useEffect, useMemo, @@ -105,8 +105,7 @@ type ResourcePolicyType = StrategyOption<"inline" | "shared">; export default function ResourceAuthenticationPage() { const { org } = useOrgContext(); - const { resource, updateResource, authInfo, updateAuthInfo } = - useResourceContext(); + const { resource, updateResource } = useResourceContext(); const { env } = useEnvContext(); @@ -125,7 +124,7 @@ export default function ResourceAuthenticationPage() { const form = useForm({ resolver: zodResolver(resourceTypeSchema), defaultValues: { - type: "inline" + type: resource.resourcePolicyId ? "shared" : "inline" } }); @@ -145,7 +144,7 @@ export default function ResourceAuthenticationPage() { enabled: selectedResourceType === "shared" }); - const { data: sharedPolicy } = useQuery({ + const { data: sharedPolicy, isLoading: isLoadingSharedPolicy } = useQuery({ ...resourcePolicyQueries.single({ resourcePolicyId: resource.resourcePolicyId ?? 1 }), @@ -157,17 +156,6 @@ export default function ResourceAuthenticationPage() { id: number; } | null>(null); - const pageLoading = isLoadingPolicies || !defaultPolicy; - - const [ - loadingRemoveResourceHeaderAuth, - setLoadingRemoveResourceHeaderAuth - ] = useState(false); - - const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); - const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); - const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); - const resourcePolicyTypes: Array = [ { id: "inline", @@ -181,6 +169,49 @@ export default function ResourceAuthenticationPage() { } ]; + useEffect(() => { + if (!isLoadingSharedPolicy && sharedPolicy) { + setSelectedPolicy({ + id: sharedPolicy.resourcePolicyId, + name: sharedPolicy.name + }); + } + }, [isLoadingSharedPolicy, sharedPolicy]); + + const [isUpdatingResource, startTransition] = useTransition(); + + async function handleSaveResourcePolicyType() { + try { + if (selectedResourceType === "inline") { + await api.post(`/resource/${resource.resourceId}`, { + resourcePolicyId: null + }); + } else { + if (!selectedPolicy) { + toast({ + title: t("error"), + description: t("resourcePolicySelectError"), + variant: "destructive" + }); + return; + } + await api.post(`/resource/${resource.resourceId}`, { + resourcePolicyId: selectedPolicy.id + }); + } + router.refresh(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } + } + + const pageLoading = + isLoadingPolicies || !defaultPolicy || isLoadingSharedPolicy; + if (pageLoading) { return <>; } @@ -214,9 +245,6 @@ export default function ResourceAuthenticationPage() { role="combobox" className={ "w-full md:w-1/2 justify-between" - // "w-45 justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent", - // "rounded-l-md rounded-r-xs" - // !proxyTarget.siteId && "text-muted-foreground" } > @@ -238,7 +266,7 @@ export default function ResourceAuthenticationPage() { /> - {t("siteNotFound")} + {t("resourcePolicyNotFound")} {policiesList.map((policy) => ( @@ -275,9 +303,10 @@ export default function ResourceAuthenticationPage() { diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 022239006..36e66ca8a 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -4,7 +4,7 @@ import type { ListClientsResponse } from "@server/routers/client"; import type { ListDomainsResponse } from "@server/routers/domain"; import type { GetResourceWhitelistResponse, - GetDefaultResourcePolicyResponse, + GetResourcePoliciesResponse, ListResourceNamesResponse, ListResourcesResponse, ListResourceRolesResponse, @@ -31,6 +31,7 @@ import { remote } from "./api"; import { durationToMs } from "./durationToMs"; import { wait } from "./wait"; import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; +import type { GetResourcePolicyResponse } from "@server/routers/policy"; export type ProductUpdate = { link: string | null; @@ -227,7 +228,7 @@ export const resourcePolicyQueries = { queryKey: ["RESOURCE_POLICIES", resourcePolicyId] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< - AxiosResponse + AxiosResponse >(`/resource-policy/${resourcePolicyId}`, { signal }); return res.data.data; @@ -364,8 +365,8 @@ export const resourceQueries = { queryKey: ["RESOURCES", resourceId, "DEFAULT_POLICY"] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< - AxiosResponse - >(`/resource/${resourceId}/default-policy`, { signal }); + AxiosResponse + >(`/resource/${resourceId}/policies`, { signal }); return res.data.data; } From 1906504a86896cae826d393de3c251ca23eb9e01 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 12 Mar 2026 18:35:50 +0100 Subject: [PATCH 82/89] =?UTF-8?q?=E2=9C=A8=20update=20shared=20policy=20wh?= =?UTF-8?q?en=20selected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../proxy/[niceId]/authentication/page.tsx | 93 ++++++------------- src/lib/queries.ts | 18 +--- 2 files changed, 30 insertions(+), 81 deletions(-) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index c1eadf5f3..bb6059588 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -1,15 +1,12 @@ "use client"; import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; -import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm"; -import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm"; import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, - SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; @@ -17,9 +14,6 @@ import { StrategySelect, type StrategyOption } from "@app/components/StrategySelect"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; import { Command, @@ -29,29 +23,11 @@ import { CommandItem, CommandList } from "@app/components/ui/command"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { InfoPopup } from "@app/components/ui/info-popup"; import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import type { ResourceContextType } from "@app/contexts/resourceContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; @@ -59,34 +35,15 @@ import { useResourceContext } from "@app/hooks/useResourceContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { cn } from "@app/lib/cn"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { - orgQueries, - resourcePolicyQueries, - resourceQueries -} from "@app/lib/queries"; -import { - ResourcePolicyContext, - ResourcePolicyProvider -} from "@app/providers/ResourcePolicyProvider"; +import { orgQueries, resourceQueries } from "@app/lib/queries"; +import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider"; import { zodResolver } from "@hookform/resolvers/zod"; import { CaretSortIcon } from "@radix-ui/react-icons"; -import { build } from "@server/build"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { UserType } from "@server/types/UserTypes"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import SetResourcePasswordForm from "components/SetResourcePasswordForm"; -import { Binary, Bot, CheckIcon, InfoIcon, Key } from "lucide-react"; +import { CheckIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import React, { - useActionState, - useEffect, - useMemo, - useRef, - useState, - useTransition -} from "react"; +import { useEffect, useState, useTransition } from "react"; import { useForm, useWatch } from "react-hook-form"; import { z } from "zod"; @@ -106,6 +63,7 @@ type ResourcePolicyType = StrategyOption<"inline" | "shared">; export default function ResourceAuthenticationPage() { const { org } = useOrgContext(); const { resource, updateResource } = useResourceContext(); + const queryClient = useQueryClient(); const { env } = useEnvContext(); @@ -115,8 +73,8 @@ export default function ResourceAuthenticationPage() { const { isPaidUser } = usePaidStatus(); - const { data: defaultPolicy, isLoading: isLoadingPolicies } = useQuery( - resourceQueries.defaultPolicy({ + const { data: policies, isLoading: isLoadingPolicies } = useQuery( + resourceQueries.policies({ resourceId: resource.resourceId }) ); @@ -144,13 +102,6 @@ export default function ResourceAuthenticationPage() { enabled: selectedResourceType === "shared" }); - const { data: sharedPolicy, isLoading: isLoadingSharedPolicy } = useQuery({ - ...resourcePolicyQueries.single({ - resourcePolicyId: resource.resourcePolicyId ?? 1 - }), - enabled: !!resource.resourcePolicyId - }); - const [selectedPolicy, setSelectedPolicy] = useState<{ name: string; id: number; @@ -170,13 +121,13 @@ export default function ResourceAuthenticationPage() { ]; useEffect(() => { - if (!isLoadingSharedPolicy && sharedPolicy) { + if (!isLoadingPolicies && policies?.sharedPolicy) { setSelectedPolicy({ - id: sharedPolicy.resourcePolicyId, - name: sharedPolicy.name + id: policies?.sharedPolicy.resourcePolicyId, + name: policies?.sharedPolicy.name }); } - }, [isLoadingSharedPolicy, sharedPolicy]); + }, [isLoadingPolicies, policies?.sharedPolicy]); const [isUpdatingResource, startTransition] = useTransition(); @@ -206,16 +157,25 @@ export default function ResourceAuthenticationPage() { description: formatAxiosError(e), variant: "destructive" }); + } finally { + await queryClient.invalidateQueries( + resourceQueries.policies({ + resourceId: resource.resourceId + }) + ); } } - const pageLoading = - isLoadingPolicies || !defaultPolicy || isLoadingSharedPolicy; + const pageLoading = isLoadingPolicies || !policies; if (pageLoading) { return <>; } + console.log({ + shared: policies.sharedPolicy + }); + return ( <> @@ -313,12 +273,15 @@ export default function ResourceAuthenticationPage() { {selectedResourceType === "inline" ? ( - + ) : ( - sharedPolicy && ( - + policies.sharedPolicy && ( + ) diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 36e66ca8a..ed2584af2 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -222,20 +222,6 @@ export const orgQueries = { }) }; -export const resourcePolicyQueries = { - single: ({ resourcePolicyId }: { resourcePolicyId: number }) => - queryOptions({ - queryKey: ["RESOURCE_POLICIES", resourcePolicyId] as const, - queryFn: async ({ signal, meta }) => { - const res = await meta!.api.get< - AxiosResponse - >(`/resource-policy/${resourcePolicyId}`, { signal }); - - return res.data.data; - } - }) -}; - export const logAnalyticsFiltersSchema = z.object({ timeStart: z .string() @@ -360,9 +346,9 @@ export const resourceQueries = { return res.data.data.whitelist; } }), - defaultPolicy: ({ resourceId }: { resourceId: number }) => + policies: ({ resourceId }: { resourceId: number }) => queryOptions({ - queryKey: ["RESOURCES", resourceId, "DEFAULT_POLICY"] as const, + queryKey: ["RESOURCES", resourceId, "POLICIES"] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse From fee44ce96009f4996a1850f21e3155fd0a3e6c30 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 12 Mar 2026 18:52:13 +0100 Subject: [PATCH 83/89] =?UTF-8?q?=E2=9C=A8=20navigate=20to=20policy=20to?= =?UTF-8?q?=20edit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 ++ .../proxy/[niceId]/authentication/page.tsx | 28 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index efaf777b5..02b5440d4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -733,6 +733,8 @@ "resourceAuthMethods": "Authentication Methods", "resourcePolicyAuthMethodsEmpty": "No authentication method", "resourcePolicyOtpEmpty": "No one time password", + "resourcePolicyReadOnly": "This policy is Read only", + "resourcePolicyReadOnlyDescription": "This resource policy is shared accross multiple resources, you cannot edit it in this page. Please go to the policy settings to edit", "resourcePolicyTypeSave": "Save Resource type", "resourcePolicySelect": "Select resource policy", "resourcePolicyNotFound": "Policy not found", diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index bb6059588..c06ce6a4e 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -1,5 +1,6 @@ "use client"; +import ActionBanner from "@app/components/ActionBanner"; import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; import { SettingsContainer, @@ -40,8 +41,9 @@ import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider"; import { zodResolver } from "@hookform/resolvers/zod"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { CheckIcon } from "lucide-react"; +import { ArrowRightIcon, CheckIcon, ShieldAlertIcon } from "lucide-react"; import { useTranslations } from "next-intl"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState, useTransition } from "react"; import { useForm, useWatch } from "react-hook-form"; @@ -282,6 +284,30 @@ export default function ResourceAuthenticationPage() { policy={policies.sharedPolicy} key={policies.sharedPolicy.resourcePolicyId} > + + } + description={t( + "resourcePolicyReadOnlyDescription" + )} + actions={ + + } + /> ) From 01b068c50f884c374d1fb5590ae58f3eda2c17d6 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 12 Mar 2026 18:53:18 +0100 Subject: [PATCH 84/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20do=20not=20edit=20ta?= =?UTF-8?q?gs=20if=20readonly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EditPolicyOtpEmailSectionForm.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx b/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx index af6edd2ce..16a73c672 100644 --- a/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx @@ -252,13 +252,15 @@ export function EditPolicyOtpEmailSectionForm({ .emails ?? [] } setTags={(newEmails) => { - form.setValue( - "emails", - newEmails as [ - Tag, - ...Tag[] - ] - ); + if (!readonly) { + form.setValue( + "emails", + newEmails as [ + Tag, + ...Tag[] + ] + ); + } }} allowDuplicates={false} sortTags={true} From b61b74b0b53630253c029c874c214234c557118e Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 12 Mar 2026 20:04:02 +0100 Subject: [PATCH 85/89] =?UTF-8?q?=F0=9F=92=AC=20=20update=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index 02b5440d4..ae82b8afa 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -734,7 +734,7 @@ "resourcePolicyAuthMethodsEmpty": "No authentication method", "resourcePolicyOtpEmpty": "No one time password", "resourcePolicyReadOnly": "This policy is Read only", - "resourcePolicyReadOnlyDescription": "This resource policy is shared accross multiple resources, you cannot edit it in this page. Please go to the policy settings to edit", + "resourcePolicyReadOnlyDescription": "This resource policy is shared accross multiple resources, you cannot edit it in this page.", "resourcePolicyTypeSave": "Save Resource type", "resourcePolicySelect": "Select resource policy", "resourcePolicyNotFound": "Policy not found", From 83a36ead1019e4c3b66c57af67e6d94ecad2c78a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 12 Mar 2026 20:22:16 +0100 Subject: [PATCH 86/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=20show=20success=20?= =?UTF-8?q?toast=20on=20resource=20policy=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/resources/proxy/[niceId]/authentication/page.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index c06ce6a4e..375c85a7c 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -153,6 +153,10 @@ export default function ResourceAuthenticationPage() { }); } router.refresh(); + toast({ + title: t("resourceUpdated"), + description: t("resourceUpdatedDescription") + }); } catch (e) { toast({ title: t("error"), From d13e6896a89cc467740a7f2d07bfac1727bba24b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 12 Mar 2026 22:11:39 +0100 Subject: [PATCH 87/89] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../proxy/[niceId]/authentication/page.tsx | 206 ++++++++++-------- 1 file changed, 111 insertions(+), 95 deletions(-) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index 375c85a7c..4b1e9f516 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -40,6 +40,8 @@ import { orgQueries, resourceQueries } from "@app/lib/queries"; import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider"; import { zodResolver } from "@hookform/resolvers/zod"; import { CaretSortIcon } from "@radix-ui/react-icons"; +import { build } from "@server/build"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowRightIcon, CheckIcon, ShieldAlertIcon } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -73,8 +75,6 @@ export default function ResourceAuthenticationPage() { const router = useRouter(); const t = useTranslations(); - const { isPaidUser } = usePaidStatus(); - const { data: policies, isLoading: isLoadingPolicies } = useQuery( resourceQueries.policies({ resourceId: resource.resourceId @@ -84,7 +84,10 @@ export default function ResourceAuthenticationPage() { const form = useForm({ resolver: zodResolver(resourceTypeSchema), defaultValues: { - type: resource.resourcePolicyId ? "shared" : "inline" + type: + build !== "oss" && resource.resourcePolicyId + ? "shared" + : "inline" } }); @@ -185,99 +188,112 @@ export default function ResourceAuthenticationPage() { return ( <> - - - - {t("resourcePolicySelectTitle")} - - - {t("resourcePolicySelectDescription")} - - - - { - form.setValue("type", value); - }} - cols={2} - /> - {selectedResourceType === "shared" && ( - - - - - - - + + + {t("resourcePolicySelectTitle")} + + + {t("resourcePolicySelectDescription")} + + + + { + form.setValue("type", value); + }} + cols={2} + /> + {selectedResourceType === "shared" && ( + + + - - + > + + {selectedPolicy + ? selectedPolicy.name + : t("resourcePolicySelect")} + + + + + + + + + + {t( + "resourcePolicyNotFound" + )} + + + {policiesList.map( + (policy) => ( + + setSelectedPolicy( + { + id: policy.resourcePolicyId, + name: policy.name + } + ) + } + > + + {policy.name} + + ) + )} + + + + + + )} + + + + + + )} + {selectedResourceType === "inline" ? ( From ccbd793f521d729a071587d533d1ed854bedee24 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 12 Mar 2026 22:13:27 +0100 Subject: [PATCH 88/89] =?UTF-8?q?=F0=9F=92=AC=20show=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/en-US.json b/messages/en-US.json index ae82b8afa..8573d38c6 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -737,6 +737,7 @@ "resourcePolicyReadOnlyDescription": "This resource policy is shared accross multiple resources, you cannot edit it in this page.", "resourcePolicyTypeSave": "Save Resource type", "resourcePolicySelect": "Select resource policy", + "resourcePolicySelectError": "Select a resource policy", "resourcePolicyNotFound": "Policy not found", "resourcePolicyRulesEmpty": "No authentication rules", "resourceAuthMethodsDescriptions": "Allow access to the resource via additional auth methods", From f3eb823bc30de4fa3b8bb7f993822cf197cd1c99 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 12 Mar 2026 22:36:29 +0100 Subject: [PATCH 89/89] =?UTF-8?q?=F0=9F=90=9B=20=20fix=20sqlite=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/sqlite/schema/schema.ts | 154 ++++++++++++++++++++++-------- 1 file changed, 116 insertions(+), 38 deletions(-) diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index a0c75b978..805f3c20b 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -104,8 +104,16 @@ export const sites = sqliteTable("sites", { export const resources = sqliteTable("resources", { resourceId: integer("resourceId").primaryKey({ autoIncrement: true }), - resourcePolicyId: integer("resourcePolicyId") - .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId").references( + () => resourcePolicies.resourcePolicyId, + { onDelete: "set null" } + ), + defaultResourcePolicyId: integer("defaultResourcePolicyId").references( + () => resourcePolicies.resourcePolicyId, + { + onDelete: "restrict" + } + ), resourceGuid: text("resourceGuid", { length: 36 }) .unique() .notNull() @@ -764,10 +772,7 @@ export const roleResources = sqliteTable("roleResources", { .references(() => roles.roleId, { onDelete: "cascade" }), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }), - resourcePolicyId: integer("resourcePolicyId") - .notNull() - .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), + .references(() => resources.resourceId, { onDelete: "cascade" }) }); export const userResources = sqliteTable("userResources", { @@ -776,10 +781,7 @@ export const userResources = sqliteTable("userResources", { .references(() => users.userId, { onDelete: "cascade" }), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }), - resourcePolicyId: integer("resourcePolicyId") - .notNull() - .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), + .references(() => resources.resourceId, { onDelete: "cascade" }) }); export const userInvites = sqliteTable("userInvites", { @@ -802,9 +804,6 @@ export const resourcePincode = sqliteTable("resourcePincode", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), - resourcePolicyId: integer("resourcePolicyId") - .notNull() - .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), pincodeHash: text("pincodeHash").notNull(), digitLength: integer("digitLength").notNull() }); @@ -816,9 +815,6 @@ export const resourcePassword = sqliteTable("resourcePassword", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), - resourcePolicyId: integer("resourcePolicyId") - .notNull() - .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), passwordHash: text("passwordHash").notNull() }); @@ -829,12 +825,50 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), - resourcePolicyId: integer("resourcePolicyId") - .notNull() - .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), headerAuthHash: text("headerAuthHash").notNull() }); +export const resourcePolicyPincode = sqliteTable("resourcePolicyPincode", { + pincodeId: integer("pincodeId").primaryKey({ autoIncrement: true }), + pincodeHash: text("pincodeHash").notNull(), + digitLength: integer("digitLength").notNull(), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) +}); + +export const resourcePolicyPassword = sqliteTable("resourcePolicyPassword", { + passwordId: integer("passwordId").primaryKey({ autoIncrement: true }), + passwordHash: text("passwordHash").notNull(), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) +}); + +export const resourcePolicyHeaderAuth = sqliteTable( + "resourcePolicyHeaderAuth", + { + headerAuthId: integer("headerAuthId").primaryKey({ + autoIncrement: true + }), + headerAuthHash: text("headerAuthHash").notNull(), + extendedCompatibility: integer("extendedCompatibility", { + mode: "boolean" + }) + .notNull() + .default(true), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) + } +); + export const resourceHeaderAuthExtendedCompatibility = sqliteTable( "resourceHeaderAuthExtendedCompatibility", { @@ -846,9 +880,6 @@ export const resourceHeaderAuthExtendedCompatibility = sqliteTable( resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), - resourcePolicyId: integer("resourcePolicyId") - .notNull() - .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), extendedCompatibilityIsActivated: integer( "extendedCompatibilityIsActivated", { mode: "boolean" } @@ -920,10 +951,7 @@ export const resourceWhitelist = sqliteTable("resourceWhitelist", { email: text("email").notNull(), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }), - resourcePolicyId: integer("resourcePolicyId") - .notNull() - .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), + .references(() => resources.resourceId, { onDelete: "cascade" }) }); export const resourceOtp = sqliteTable("resourceOtp", { @@ -933,9 +961,6 @@ export const resourceOtp = sqliteTable("resourceOtp", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), - resourcePolicyId: integer("resourcePolicyId") - .notNull() - .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), email: text("email").notNull(), otpHash: text("otpHash").notNull(), expiresAt: integer("expiresAt").notNull() @@ -951,9 +976,6 @@ export const resourceRules = sqliteTable("resourceRules", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), - resourcePolicyId: integer("resourcePolicyId") - .notNull() - .references(() => resourcePolicies.resourcePolicyId, { onDelete: "cascade" }), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), priority: integer("priority").notNull(), action: text("action").notNull(), // ACCEPT, DROP, PASS @@ -961,12 +983,66 @@ export const resourceRules = sqliteTable("resourceRules", { value: text("value").notNull() }); +export const rolePolicies = sqliteTable("rolePolicies", { + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) +}); + +export const userPolicies = sqliteTable("userPolicies", { + userId: text("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) +}); + +export const resourcePolicyWhiteList = sqliteTable("resourcePolicyWhitelist", { + whitelistId: integer("id").primaryKey({ autoIncrement: true }), + email: text("email").notNull(), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) +}); + +export const resourcePolicyRules = sqliteTable("resourcePolicyRules", { + ruleId: integer("ruleId").primaryKey({ autoIncrement: true }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + priority: integer("priority").notNull(), + action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(), + match: text("match").$type<"CIDR" | "PATH" | "IP">().notNull(), + value: text("value").notNull() +}); + export const resourcePolicies = sqliteTable("resourcePolicies", { - resourcePolicyId: integer('resourcePolicyId').primaryKey(), - sso: integer("sso", { mode: 'boolean' }).notNull().default(true), - emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: 'boolean' }).notNull().default(false), + resourcePolicyId: integer("resourcePolicyId").primaryKey(), + sso: integer("sso", { mode: "boolean" }).notNull().default(true), + applyRules: integer("applyRules", { mode: "boolean" }) + .notNull() + .default(false), + scope: text("scope") + .$type<"global" | "resource">() + .notNull() + .default("global"), + emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" }) + .notNull() + .default(false), niceId: text("niceId").notNull(), - isDefault: integer("isDefault", { mode: 'boolean' }).notNull().default(true), idpId: integer("idpId").references(() => idp.idpId, { onDelete: "set null" }), @@ -975,10 +1051,9 @@ export const resourcePolicies = sqliteTable("resourcePolicies", { .references(() => orgs.orgId, { onDelete: "cascade" }) - .notNull(), + .notNull() }); - export const supporterKey = sqliteTable("supporterKey", { keyId: integer("keyId").primaryKey({ autoIncrement: true }), key: text("key").notNull(), @@ -1215,3 +1290,6 @@ export type DeviceWebAuthCode = InferSelectModel; export type RoundTripMessageTracker = InferSelectModel< typeof roundTripMessageTracker >; +export type ResourcePolicy = InferSelectModel; +export type RolePolicy = InferSelectModel; +export type UserPolicy = InferSelectModel;