mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-07 00:39:53 +00:00
Compare commits
113 Commits
main
...
resource-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4b3656fad | ||
|
|
54c1dd3bae | ||
|
|
a8f4d2b7d1 | ||
|
|
51f1693dbd | ||
|
|
b33a6e6fac | ||
|
|
fc2c13a686 | ||
|
|
f4602a120e | ||
|
|
7ccceeea0d | ||
|
|
f81f78f294 | ||
|
|
6cab223f12 | ||
|
|
7b05c02508 | ||
|
|
5922bfb1a0 | ||
|
|
43f2e32231 | ||
|
|
20ebdc6289 | ||
|
|
a80ae49a33 | ||
|
|
660197eef1 | ||
|
|
f3eb823bc3 | ||
|
|
61c13db090 | ||
|
|
ccbd793f52 | ||
|
|
d13e6896a8 | ||
|
|
83a36ead10 | ||
|
|
b61b74b0b5 | ||
|
|
01b068c50f | ||
|
|
fee44ce960 | ||
|
|
1906504a86 | ||
|
|
36bcba332c | ||
|
|
304ab1964c | ||
|
|
b286096c7b | ||
|
|
a22a4b6e74 | ||
|
|
9a680d2374 | ||
|
|
f80e212b07 | ||
|
|
8a39b3fd45 | ||
|
|
61ec938b00 | ||
|
|
6686de6788 | ||
|
|
79636cbb30 | ||
|
|
2fa1bc6cdc | ||
|
|
c5f6d822ca | ||
|
|
4de4bf9625 | ||
|
|
5d956080f2 | ||
|
|
f8e18de2fc | ||
|
|
884482ec35 | ||
|
|
9b43948fa4 | ||
|
|
bcd6cd99cc | ||
|
|
37ceba6b81 | ||
|
|
dfe42e9016 | ||
|
|
38aa2dace8 | ||
|
|
136c3eff0c | ||
|
|
642999c8b1 | ||
|
|
c5fc49b4fa | ||
|
|
cd5a38b1eb | ||
|
|
595842c2c9 | ||
|
|
82d5276ade | ||
|
|
51eb782831 | ||
|
|
de2980e1bc | ||
|
|
8a3c0d9a08 | ||
|
|
1a5e9f1005 | ||
|
|
f42c013f33 | ||
|
|
42c9bda939 | ||
|
|
cbce9fae3a | ||
|
|
e44b15ecd5 | ||
|
|
7f6ca31757 | ||
|
|
a1eb248474 | ||
|
|
be2b1fd1ce | ||
|
|
20b65f549e | ||
|
|
1dc8be373c | ||
|
|
22b2e6b3d4 | ||
|
|
89e7107a47 | ||
|
|
0a69131c38 | ||
|
|
590f2c29b3 | ||
|
|
0ddcce6fe1 | ||
|
|
8a54fb7f23 | ||
|
|
5c280b024e | ||
|
|
033cc62ce7 | ||
|
|
4c69b7a64e | ||
|
|
e7ab9b3f37 | ||
|
|
3143662f82 | ||
|
|
18964ba2a3 | ||
|
|
f862404c5c | ||
|
|
c292578f80 | ||
|
|
7b02d4104d | ||
|
|
2ef5d90e13 | ||
|
|
d6a8021613 | ||
|
|
c5231d37f6 | ||
|
|
4d803a40c9 | ||
|
|
1d709b551a | ||
|
|
335411de4c | ||
|
|
0e4abdf4b6 | ||
|
|
267b40b73c | ||
|
|
ba9a0c5e3c | ||
|
|
9e0b7ff0d7 | ||
|
|
003bf7fdf3 | ||
|
|
c3fdda026b | ||
|
|
a53363d064 | ||
|
|
ee21e1faa7 | ||
|
|
e409a34a09 | ||
|
|
7177ab7f77 | ||
|
|
801f6fb661 | ||
|
|
805d82b8d9 | ||
|
|
bd6d790495 | ||
|
|
2305163474 | ||
|
|
dda53dcb16 | ||
|
|
2c3e768867 | ||
|
|
8d682ed9ad | ||
|
|
47fe497ca1 | ||
|
|
4d5f364663 | ||
|
|
c3db8b972f | ||
|
|
cfced63ba1 | ||
|
|
51aa55f963 | ||
|
|
e7df24841e | ||
|
|
e6fd4c32c4 | ||
|
|
f6590aedbd | ||
|
|
3cb9e02533 | ||
|
|
4d792350ef |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -17,9 +17,9 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite*
|
||||||
!Dockerfile.sqlite
|
!Dockerfile.sqlite
|
||||||
*.sqlite3
|
*.sqlite3*
|
||||||
*.log
|
*.log
|
||||||
.machinelogs*.json
|
.machinelogs*.json
|
||||||
*-audit.json
|
*-audit.json
|
||||||
@@ -54,3 +54,4 @@ hydrateSaas.ts
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
drizzle.config.ts
|
drizzle.config.ts
|
||||||
server/setup/migrations.ts
|
server/setup/migrations.ts
|
||||||
|
solo.yml
|
||||||
@@ -204,11 +204,33 @@
|
|||||||
"resourcesSearch": "Search resources...",
|
"resourcesSearch": "Search resources...",
|
||||||
"resourceAdd": "Add Resource",
|
"resourceAdd": "Add Resource",
|
||||||
"resourceErrorDelte": "Error deleting 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",
|
||||||
|
"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",
|
||||||
|
"resourcePoliciesSeeAll": "See All Policies",
|
||||||
|
"resourcePolicyAuthMethodAdd": "Add Authentication Method",
|
||||||
|
"resourcePolicyOtpEmailAdd": "Add OTP emails",
|
||||||
|
"resourcePolicyRulesAdd": "Add Rules",
|
||||||
|
"resourcePolicyAuthMethodsDescription": "Allow access to resources via additional auth methods",
|
||||||
|
"resourcePolicyUsersRolesDescription": "Configure which users and roles can visit associated resources",
|
||||||
|
"rulesResourcePolicyDescription": "Configure rules to control access resources associated to this policy",
|
||||||
"authentication": "Authentication",
|
"authentication": "Authentication",
|
||||||
"protected": "Protected",
|
"protected": "Protected",
|
||||||
"notProtected": "Not Protected",
|
"notProtected": "Not Protected",
|
||||||
"resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.",
|
"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?",
|
"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",
|
"resourceHTTP": "HTTPS Resource",
|
||||||
"resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.",
|
"resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.",
|
||||||
"resourceRaw": "Raw TCP/UDP Resource",
|
"resourceRaw": "Raw TCP/UDP Resource",
|
||||||
@@ -249,6 +271,8 @@
|
|||||||
"resourceLearnRaw": "Learn how to configure TCP/UDP resources",
|
"resourceLearnRaw": "Learn how to configure TCP/UDP resources",
|
||||||
"resourceBack": "Back to Resources",
|
"resourceBack": "Back to Resources",
|
||||||
"resourceGoTo": "Go to Resource",
|
"resourceGoTo": "Go to Resource",
|
||||||
|
"resourcePolicyDelete": "Delete Resource Policy",
|
||||||
|
"resourcePolicyDeleteConfirm": "Confirm Delete Resource Policy",
|
||||||
"resourceDelete": "Delete Resource",
|
"resourceDelete": "Delete Resource",
|
||||||
"resourceDeleteConfirm": "Confirm Delete Resource",
|
"resourceDeleteConfirm": "Confirm Delete Resource",
|
||||||
"visibility": "Visibility",
|
"visibility": "Visibility",
|
||||||
@@ -261,6 +285,8 @@
|
|||||||
"rules": "Rules",
|
"rules": "Rules",
|
||||||
"resourceSettingDescription": "Configure the settings on the resource",
|
"resourceSettingDescription": "Configure the settings on the resource",
|
||||||
"resourceSetting": "{resourceName} Settings",
|
"resourceSetting": "{resourceName} Settings",
|
||||||
|
"resourcePolicySettingDescription": "Configure the settings on the resource policy",
|
||||||
|
"resourcePolicySetting": "{policyName} Settings",
|
||||||
"alwaysAllow": "Bypass Auth",
|
"alwaysAllow": "Bypass Auth",
|
||||||
"alwaysDeny": "Block Access",
|
"alwaysDeny": "Block Access",
|
||||||
"passToAuth": "Pass to Auth",
|
"passToAuth": "Pass to Auth",
|
||||||
@@ -731,6 +757,16 @@
|
|||||||
"rulesNoOne": "No rules. Add a rule using the form.",
|
"rulesNoOne": "No rules. Add a rule using the form.",
|
||||||
"rulesOrder": "Rules are evaluated by priority in ascending order.",
|
"rulesOrder": "Rules are evaluated by priority in ascending order.",
|
||||||
"rulesSubmit": "Save Rules",
|
"rulesSubmit": "Save Rules",
|
||||||
|
"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",
|
||||||
|
"authMethodsSave": "Save auth methods",
|
||||||
|
"rulesSave": "Save Rules",
|
||||||
"resourceErrorCreate": "Error creating resource",
|
"resourceErrorCreate": "Error creating resource",
|
||||||
"resourceErrorCreateDescription": "An error occurred when creating the resource",
|
"resourceErrorCreateDescription": "An error occurred when creating the resource",
|
||||||
"resourceErrorCreateMessage": "Error creating resource:",
|
"resourceErrorCreateMessage": "Error creating resource:",
|
||||||
@@ -794,6 +830,16 @@
|
|||||||
"pincodeAdd": "Add PIN Code",
|
"pincodeAdd": "Add PIN Code",
|
||||||
"pincodeRemove": "Remove PIN Code",
|
"pincodeRemove": "Remove PIN Code",
|
||||||
"resourceAuthMethods": "Authentication Methods",
|
"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 on this page.",
|
||||||
|
"resourcePolicyTypeSave": "Save Resource type",
|
||||||
|
"resourcePolicySelect": "Select resource policy",
|
||||||
|
"resourcePolicySelectError": "Select a resource policy",
|
||||||
|
"resourcePolicyNotFound": "Policy not found",
|
||||||
|
"resourcePolicySearch": "Search policies",
|
||||||
|
"resourcePolicyRulesEmpty": "No authentication rules",
|
||||||
"resourceAuthMethodsDescriptions": "Allow access to the resource via additional auth methods",
|
"resourceAuthMethodsDescriptions": "Allow access to the resource via additional auth methods",
|
||||||
"resourceAuthSettingsSave": "Saved successfully",
|
"resourceAuthSettingsSave": "Saved successfully",
|
||||||
"resourceAuthSettingsSaveDescription": "Authentication settings have been saved",
|
"resourceAuthSettingsSaveDescription": "Authentication settings have been saved",
|
||||||
@@ -829,6 +875,12 @@
|
|||||||
"resourcePincodeSetupTitle": "Set Pincode",
|
"resourcePincodeSetupTitle": "Set Pincode",
|
||||||
"resourcePincodeSetupTitleDescription": "Set a pincode to protect this resource",
|
"resourcePincodeSetupTitleDescription": "Set a pincode to protect this resource",
|
||||||
"resourceRoleDescription": "Admins can always access 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",
|
"resourceUsersRoles": "Access Controls",
|
||||||
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
|
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
|
||||||
"resourceUsersRolesSubmit": "Save Access Controls",
|
"resourceUsersRolesSubmit": "Save Access Controls",
|
||||||
@@ -1358,6 +1410,8 @@
|
|||||||
"sidebarResources": "Resources",
|
"sidebarResources": "Resources",
|
||||||
"sidebarProxyResources": "Public",
|
"sidebarProxyResources": "Public",
|
||||||
"sidebarClientResources": "Private",
|
"sidebarClientResources": "Private",
|
||||||
|
"sidebarPolicies": "Policies",
|
||||||
|
"sidebarResourcePolicies": "Resources",
|
||||||
"sidebarAccessControl": "Access Control",
|
"sidebarAccessControl": "Access Control",
|
||||||
"sidebarLogsAndAnalytics": "Logs & Analytics",
|
"sidebarLogsAndAnalytics": "Logs & Analytics",
|
||||||
"sidebarTeam": "Team",
|
"sidebarTeam": "Team",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { and, eq, inArray } from "drizzle-orm";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export enum ActionsEnum {
|
export enum ActionsEnum {
|
||||||
createOrgUser = "createOrgUser",
|
createOrgUser = "createOrgUser",
|
||||||
@@ -152,7 +153,21 @@ export enum ActionsEnum {
|
|||||||
createHealthCheck = "createHealthCheck",
|
createHealthCheck = "createHealthCheck",
|
||||||
updateHealthCheck = "updateHealthCheck",
|
updateHealthCheck = "updateHealthCheck",
|
||||||
deleteHealthCheck = "deleteHealthCheck",
|
deleteHealthCheck = "deleteHealthCheck",
|
||||||
listHealthChecks = "listHealthChecks"
|
listHealthChecks = "listHealthChecks",
|
||||||
|
listResourcePolicies = "listResourcePolicies",
|
||||||
|
getResourcePolicy = "getResourcePolicy",
|
||||||
|
createResourcePolicy = "createResourcePolicy",
|
||||||
|
updateResourcePolicy = "updateResourcePolicy",
|
||||||
|
deleteResourcePolicy = "deleteResourcePolicy",
|
||||||
|
listResourcePolicyRoles = "listResourcePolicyRoles",
|
||||||
|
setResourcePolicyRoles = "setResourcePolicyRoles",
|
||||||
|
listResourcePolicyUsers = "listResourcePolicyUsers",
|
||||||
|
setResourcePolicyUsers = "setResourcePolicyUsers",
|
||||||
|
setResourcePolicyPassword = "setResourcePolicyPassword",
|
||||||
|
setResourcePolicyPincode = "setResourcePolicyPincode",
|
||||||
|
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
|
||||||
|
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
|
||||||
|
setResourcePolicyRules = "setResourcePolicyRules"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
@@ -185,6 +200,23 @@ export async function checkUserActionPermission(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If no direct permission, check role-based permission (any of user's roles)
|
||||||
|
const roleActionPermission = await db
|
||||||
|
.select()
|
||||||
|
.from(roleActions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleActions.actionId, actionId),
|
||||||
|
inArray(roleActions.roleId, userOrgRoleIds),
|
||||||
|
eq(roleActions.orgId, req.userOrgId!)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (roleActionPermission.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the user has direct permission for the action in the current org
|
// Check if the user has direct permission for the action in the current org
|
||||||
const userActionPermission = await db
|
const userActionPermission = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -202,20 +234,7 @@ export async function checkUserActionPermission(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no direct permission, check role-based permission (any of user's roles)
|
return false;
|
||||||
const roleActionPermission = await db
|
|
||||||
.select()
|
|
||||||
.from(roleActions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(roleActions.actionId, actionId),
|
|
||||||
inArray(roleActions.roleId, userOrgRoleIds),
|
|
||||||
eq(roleActions.orgId, req.userOrgId!)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
return roleActionPermission.length > 0;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error checking user action permission:", error);
|
console.error("Error checking user action permission:", error);
|
||||||
throw createHttpError(
|
throw createHttpError(
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { clients, db, resources, siteResources } from "@server/db";
|
import {
|
||||||
|
clients,
|
||||||
|
db,
|
||||||
|
resourcePolicies,
|
||||||
|
resources,
|
||||||
|
siteResources
|
||||||
|
} from "@server/db";
|
||||||
import { randomInt } from "crypto";
|
import { randomInt } from "crypto";
|
||||||
import { exitNodes, sites } from "@server/db";
|
import { exitNodes, sites } from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
@@ -107,6 +113,35 @@ export async function getUniqueResourceName(orgId: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUniqueResourcePolicyName(
|
||||||
|
orgId: string
|
||||||
|
): Promise<string> {
|
||||||
|
let loops = 0;
|
||||||
|
while (true) {
|
||||||
|
if (loops > 100) {
|
||||||
|
throw new Error("Could not generate a unique name");
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = generateName();
|
||||||
|
const policyCount = await db
|
||||||
|
.select({
|
||||||
|
niceId: resourcePolicies.niceId,
|
||||||
|
orgId: resourcePolicies.orgId
|
||||||
|
})
|
||||||
|
.from(resourcePolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourcePolicies.niceId, name),
|
||||||
|
eq(resourcePolicies.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (policyCount.length === 0) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
loops++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUniqueSiteResourceName(
|
export async function getUniqueSiteResourceName(
|
||||||
orgId: string
|
orgId: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ export const sites = pgTable("sites", {
|
|||||||
|
|
||||||
export const resources = pgTable("resources", {
|
export const resources = pgTable("resources", {
|
||||||
resourceId: serial("resourceId").primaryKey(),
|
resourceId: serial("resourceId").primaryKey(),
|
||||||
|
resourcePolicyId: integer("resourcePolicyId").references(
|
||||||
|
() => resourcePolicies.resourcePolicyId,
|
||||||
|
{ onDelete: "set null" }
|
||||||
|
),
|
||||||
|
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
|
||||||
|
() => resourcePolicies.resourcePolicyId,
|
||||||
|
{
|
||||||
|
onDelete: "restrict"
|
||||||
|
}
|
||||||
|
),
|
||||||
resourceGuid: varchar("resourceGuid", { length: 36 })
|
resourceGuid: varchar("resourceGuid", { length: 36 })
|
||||||
.unique()
|
.unique()
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -196,9 +206,11 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
|
|||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
})
|
})
|
||||||
.notNull(),
|
.notNull(),
|
||||||
siteId: integer("siteId").references(() => sites.siteId, {
|
siteId: integer("siteId")
|
||||||
onDelete: "cascade"
|
.references(() => sites.siteId, {
|
||||||
}).notNull(),
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
name: varchar("name"),
|
name: varchar("name"),
|
||||||
hcEnabled: boolean("hcEnabled").notNull().default(false),
|
hcEnabled: boolean("hcEnabled").notNull().default(false),
|
||||||
hcPath: varchar("hcPath"),
|
hcPath: varchar("hcPath"),
|
||||||
@@ -521,6 +533,38 @@ export const userResources = pgTable("userResources", {
|
|||||||
.references(() => resources.resourceId, { onDelete: "cascade" })
|
.references(() => resources.resourceId, { onDelete: "cascade" })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const rolePolicies = pgTable("rolePolicies", {
|
||||||
|
roleId: integer("roleId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||||
|
resourcePolicyId: integer("resourcePolicyId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resourcePolicies.resourcePolicyId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userPolicies = pgTable("userPolicies", {
|
||||||
|
userId: varchar("userId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.userId, { onDelete: "cascade" }),
|
||||||
|
resourcePolicyId: integer("resourcePolicyId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resourcePolicies.resourcePolicyId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
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", {
|
export const userInvites = pgTable("userInvites", {
|
||||||
inviteId: varchar("inviteId").primaryKey(),
|
inviteId: varchar("inviteId").primaryKey(),
|
||||||
orgId: varchar("orgId")
|
orgId: varchar("orgId")
|
||||||
@@ -586,6 +630,40 @@ export const resourceHeaderAuthExtendedCompatibility = pgTable(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const resourcePolicyPincode = pgTable("resourcePolicyPincode", {
|
||||||
|
pincodeId: serial("pincodeId").primaryKey(),
|
||||||
|
pincodeHash: varchar("pincodeHash").notNull(),
|
||||||
|
digitLength: integer("digitLength").notNull(),
|
||||||
|
resourcePolicyId: integer("resourcePolicyId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resourcePolicies.resourcePolicyId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resourcePolicyPassword = pgTable("resourcePolicyPassword", {
|
||||||
|
passwordId: serial("passwordId").primaryKey(),
|
||||||
|
passwordHash: varchar("passwordHash").notNull(),
|
||||||
|
resourcePolicyId: integer("resourcePolicyId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resourcePolicies.resourcePolicyId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resourcePolicyHeaderAuth = pgTable("resourcePolicyHeaderAuth", {
|
||||||
|
headerAuthId: serial("headerAuthId").primaryKey(),
|
||||||
|
headerAuthHash: varchar("headerAuthHash").notNull(),
|
||||||
|
extendedCompatibility: boolean("extendedCompatibility")
|
||||||
|
.notNull()
|
||||||
|
.default(true),
|
||||||
|
resourcePolicyId: integer("resourcePolicyId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resourcePolicies.resourcePolicyId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
export const resourceAccessToken = pgTable("resourceAccessToken", {
|
export const resourceAccessToken = pgTable("resourceAccessToken", {
|
||||||
accessTokenId: varchar("accessTokenId").primaryKey(),
|
accessTokenId: varchar("accessTokenId").primaryKey(),
|
||||||
orgId: varchar("orgId")
|
orgId: varchar("orgId")
|
||||||
@@ -679,6 +757,43 @@ export const resourceRules = pgTable("resourceRules", {
|
|||||||
value: varchar("value").notNull()
|
value: varchar("value").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const resourcePolicyRules = pgTable("resourcePolicyRules", {
|
||||||
|
ruleId: serial("ruleId").primaryKey(),
|
||||||
|
resourcePolicyId: integer("resourcePolicyId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resourcePolicies.resourcePolicyId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
priority: integer("priority").notNull(),
|
||||||
|
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
||||||
|
match: varchar("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
|
||||||
|
value: varchar("value").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
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()
|
||||||
|
.default("global"),
|
||||||
|
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
idpId: integer("idpId").references(() => idp.idpId, {
|
||||||
|
onDelete: "set null"
|
||||||
|
}),
|
||||||
|
niceId: text("niceId").notNull(),
|
||||||
|
name: varchar("name").notNull(),
|
||||||
|
orgId: varchar("orgId")
|
||||||
|
.references(() => orgs.orgId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export const supporterKey = pgTable("supporterKey", {
|
export const supporterKey = pgTable("supporterKey", {
|
||||||
keyId: serial("keyId").primaryKey(),
|
keyId: serial("keyId").primaryKey(),
|
||||||
key: varchar("key").notNull(),
|
key: varchar("key").notNull(),
|
||||||
@@ -1097,19 +1212,30 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
|
|||||||
complete: boolean("complete").notNull().default(false)
|
complete: boolean("complete").notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const statusHistory = pgTable("statusHistory", {
|
export const statusHistory = pgTable(
|
||||||
id: serial("id").primaryKey(),
|
"statusHistory",
|
||||||
entityType: varchar("entityType").notNull(),
|
{
|
||||||
entityId: integer("entityId").notNull(),
|
id: serial("id").primaryKey(),
|
||||||
orgId: varchar("orgId")
|
entityType: varchar("entityType").notNull(),
|
||||||
.notNull()
|
entityId: integer("entityId").notNull(),
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
orgId: varchar("orgId")
|
||||||
status: varchar("status").notNull(),
|
.notNull()
|
||||||
timestamp: integer("timestamp").notNull(),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
}, (table) => [
|
status: varchar("status").notNull(),
|
||||||
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
|
timestamp: integer("timestamp").notNull()
|
||||||
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
|
},
|
||||||
]);
|
(table) => [
|
||||||
|
index("idx_statusHistory_entity").on(
|
||||||
|
table.entityType,
|
||||||
|
table.entityId,
|
||||||
|
table.timestamp
|
||||||
|
),
|
||||||
|
index("idx_statusHistory_org_timestamp").on(
|
||||||
|
table.orgId,
|
||||||
|
table.timestamp
|
||||||
|
)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
@@ -1179,3 +1305,6 @@ export type RoundTripMessageTracker = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type Network = InferSelectModel<typeof networks>;
|
export type Network = InferSelectModel<typeof networks>;
|
||||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||||
|
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
||||||
|
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||||
|
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ import {
|
|||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
ResourceHeaderAuth,
|
ResourceHeaderAuth,
|
||||||
resourceRules,
|
resourceRules,
|
||||||
|
resourcePolicyRules,
|
||||||
resources,
|
resources,
|
||||||
roleResources,
|
roleResources,
|
||||||
|
rolePolicies,
|
||||||
sessions,
|
sessions,
|
||||||
userResources,
|
userResources,
|
||||||
|
userPolicies,
|
||||||
users,
|
users,
|
||||||
ResourceHeaderAuthExtendedCompatibility,
|
ResourceHeaderAuthExtendedCompatibility,
|
||||||
resourceHeaderAuthExtendedCompatibility
|
resourceHeaderAuthExtendedCompatibility
|
||||||
@@ -154,58 +157,126 @@ export async function getRoleName(roleId: number): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if role has access to resource
|
* Check if role has access to resource (direct or via resource policy)
|
||||||
*/
|
*/
|
||||||
export async function getRoleResourceAccess(
|
export async function getRoleResourceAccess(
|
||||||
resourceId: number,
|
resourceId: number,
|
||||||
roleIds: number[]
|
roleIds: number[]
|
||||||
) {
|
) {
|
||||||
const roleResourceAccess = await db
|
const [direct, viaPolicies] = await Promise.all([
|
||||||
.select()
|
db
|
||||||
.from(roleResources)
|
.select()
|
||||||
.where(
|
.from(roleResources)
|
||||||
and(
|
.where(
|
||||||
eq(roleResources.resourceId, resourceId),
|
and(
|
||||||
inArray(roleResources.roleId, roleIds)
|
eq(roleResources.resourceId, resourceId),
|
||||||
|
inArray(roleResources.roleId, roleIds)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
roleId: rolePolicies.roleId,
|
||||||
|
resourcePolicyId: rolePolicies.resourcePolicyId
|
||||||
|
})
|
||||||
|
.from(rolePolicies)
|
||||||
|
.innerJoin(
|
||||||
|
resources,
|
||||||
|
eq(resources.resourcePolicyId, rolePolicies.resourcePolicyId)
|
||||||
)
|
)
|
||||||
);
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
inArray(rolePolicies.roleId, roleIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
return roleResourceAccess.length > 0 ? roleResourceAccess : null;
|
const combined = [...direct, ...viaPolicies];
|
||||||
|
return combined.length > 0 ? combined : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user has direct access to resource
|
* Check if user has access to resource (direct or via resource policy)
|
||||||
*/
|
*/
|
||||||
export async function getUserResourceAccess(
|
export async function getUserResourceAccess(
|
||||||
userId: string,
|
userId: string,
|
||||||
resourceId: number
|
resourceId: number
|
||||||
) {
|
) {
|
||||||
const userResourceAccess = await db
|
const [direct, viaPolicies] = await Promise.all([
|
||||||
.select()
|
db
|
||||||
.from(userResources)
|
.select()
|
||||||
.where(
|
.from(userResources)
|
||||||
and(
|
.where(
|
||||||
eq(userResources.userId, userId),
|
and(
|
||||||
eq(userResources.resourceId, resourceId)
|
eq(userResources.userId, userId),
|
||||||
|
eq(userResources.resourceId, resourceId)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.limit(1),
|
||||||
.limit(1);
|
db
|
||||||
|
.select({
|
||||||
|
userId: userPolicies.userId,
|
||||||
|
resourcePolicyId: userPolicies.resourcePolicyId
|
||||||
|
})
|
||||||
|
.from(userPolicies)
|
||||||
|
.innerJoin(
|
||||||
|
resources,
|
||||||
|
eq(resources.resourcePolicyId, userPolicies.resourcePolicyId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
eq(userPolicies.userId, userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
]);
|
||||||
|
|
||||||
return userResourceAccess.length > 0 ? userResourceAccess[0] : null;
|
return direct[0] ?? viaPolicies[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get resource rules for a given resource
|
* Get resource rules for a given resource (direct and via resource policy)
|
||||||
*/
|
*/
|
||||||
export async function getResourceRules(
|
export async function getResourceRules(
|
||||||
resourceId: number
|
resourceId: number
|
||||||
): Promise<ResourceRule[]> {
|
): Promise<ResourceRule[]> {
|
||||||
const rules = await db
|
const [directRules, policyRules] = await Promise.all([
|
||||||
.select()
|
db
|
||||||
.from(resourceRules)
|
.select()
|
||||||
.where(eq(resourceRules.resourceId, resourceId));
|
.from(resourceRules)
|
||||||
|
.where(eq(resourceRules.resourceId, resourceId)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
ruleId: resourcePolicyRules.ruleId,
|
||||||
|
resourceId: sql<number>`${resourceId}`,
|
||||||
|
enabled: resourcePolicyRules.enabled,
|
||||||
|
priority: resourcePolicyRules.priority,
|
||||||
|
action: resourcePolicyRules.action,
|
||||||
|
match: resourcePolicyRules.match,
|
||||||
|
value: resourcePolicyRules.value
|
||||||
|
})
|
||||||
|
.from(resourcePolicyRules)
|
||||||
|
.innerJoin(
|
||||||
|
resources,
|
||||||
|
eq(
|
||||||
|
resources.resourcePolicyId,
|
||||||
|
resourcePolicyRules.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
]);
|
||||||
|
|
||||||
return rules;
|
const maxDirectPriority = directRules.reduce(
|
||||||
|
(max, r) => Math.max(max, r.priority),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const offsetPolicyRules = policyRules.map((r) => ({
|
||||||
|
...r,
|
||||||
|
priority: maxDirectPriority + r.priority
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...directRules, ...offsetPolicyRules] as ResourceRule[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -121,6 +121,16 @@ export const sites = sqliteTable("sites", {
|
|||||||
|
|
||||||
export const resources = sqliteTable("resources", {
|
export const resources = sqliteTable("resources", {
|
||||||
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
|
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
|
||||||
|
resourcePolicyId: integer("resourcePolicyId").references(
|
||||||
|
() => resourcePolicies.resourcePolicyId,
|
||||||
|
{ onDelete: "set null" }
|
||||||
|
),
|
||||||
|
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
|
||||||
|
() => resourcePolicies.resourcePolicyId,
|
||||||
|
{
|
||||||
|
onDelete: "restrict"
|
||||||
|
}
|
||||||
|
),
|
||||||
resourceGuid: text("resourceGuid", { length: 36 })
|
resourceGuid: text("resourceGuid", { length: 36 })
|
||||||
.unique()
|
.unique()
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -219,9 +229,11 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
|||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
})
|
})
|
||||||
.notNull(),
|
.notNull(),
|
||||||
siteId: integer("siteId").references(() => sites.siteId, {
|
siteId: integer("siteId")
|
||||||
onDelete: "cascade"
|
.references(() => sites.siteId, {
|
||||||
}).notNull(),
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
hcEnabled: integer("hcEnabled", { mode: "boolean" })
|
hcEnabled: integer("hcEnabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -909,6 +921,47 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", {
|
|||||||
headerAuthHash: text("headerAuthHash").notNull()
|
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(
|
export const resourceHeaderAuthExtendedCompatibility = sqliteTable(
|
||||||
"resourceHeaderAuthExtendedCompatibility",
|
"resourceHeaderAuthExtendedCompatibility",
|
||||||
{
|
{
|
||||||
@@ -1023,6 +1076,77 @@ export const resourceRules = sqliteTable("resourceRules", {
|
|||||||
value: text("value").notNull()
|
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),
|
||||||
|
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(),
|
||||||
|
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", {
|
export const supporterKey = sqliteTable("supporterKey", {
|
||||||
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
|
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
|
||||||
key: text("key").notNull(),
|
key: text("key").notNull(),
|
||||||
@@ -1196,19 +1320,30 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
|
|||||||
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
|
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const statusHistory = sqliteTable("statusHistory", {
|
export const statusHistory = sqliteTable(
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
"statusHistory",
|
||||||
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
{
|
||||||
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
orgId: text("orgId")
|
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
||||||
.notNull()
|
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
orgId: text("orgId")
|
||||||
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
|
.notNull()
|
||||||
timestamp: integer("timestamp").notNull(), // unix epoch seconds
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
}, (table) => [
|
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
|
||||||
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
|
timestamp: integer("timestamp").notNull() // unix epoch seconds
|
||||||
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
|
},
|
||||||
]);
|
(table) => [
|
||||||
|
index("idx_statusHistory_entity").on(
|
||||||
|
table.entityType,
|
||||||
|
table.entityId,
|
||||||
|
table.timestamp
|
||||||
|
),
|
||||||
|
index("idx_statusHistory_org_timestamp").on(
|
||||||
|
table.orgId,
|
||||||
|
table.timestamp
|
||||||
|
)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
@@ -1278,3 +1413,6 @@ export type RoundTripMessageTracker = InferSelectModel<
|
|||||||
typeof roundTripMessageTracker
|
typeof roundTripMessageTracker
|
||||||
>;
|
>;
|
||||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||||
|
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
||||||
|
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||||
|
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export enum TierFeature {
|
|||||||
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
||||||
StandaloneHealthChecks = "standaloneHealthChecks",
|
StandaloneHealthChecks = "standaloneHealthChecks",
|
||||||
AlertingRules = "alertingRules",
|
AlertingRules = "alertingRules",
|
||||||
WildcardSubdomain = "wildcardSubdomain"
|
WildcardSubdomain = "wildcardSubdomain",
|
||||||
|
ResourcePolicies = "resourcePolicies"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||||
@@ -66,5 +67,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
|||||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
|
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
|
||||||
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
|
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
|
||||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
|
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
|
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"]
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -162,9 +162,10 @@ export const HeaderSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Schema for individual resource
|
// Schema for individual resource
|
||||||
export const ResourceSchema = z
|
export const PublicResourceSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
policy: z.string().optional(),
|
||||||
protocol: z.enum(["http", "tcp", "udp"]).optional(),
|
protocol: z.enum(["http", "tcp", "udp"]).optional(),
|
||||||
ssl: z.boolean().optional(),
|
ssl: z.boolean().optional(),
|
||||||
scheme: z.enum(["http", "https"]).optional(),
|
scheme: z.enum(["http", "https"]).optional(),
|
||||||
@@ -340,7 +341,8 @@ export const ResourceSchema = z
|
|||||||
if (parts.includes("*", 1)) return false; // no further wildcards
|
if (parts.includes("*", 1)) return false; // no further wildcards
|
||||||
if (parts.length < 3) return false; // need at least *.label.tld
|
if (parts.length < 3) return false; // need at least *.label.tld
|
||||||
|
|
||||||
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
|
const labelRegex =
|
||||||
|
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
|
||||||
return parts.slice(1).every((label) => labelRegex.test(label));
|
return parts.slice(1).every((label) => labelRegex.test(label));
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -354,7 +356,7 @@ export function isTargetsOnlyResource(resource: any): boolean {
|
|||||||
return Object.keys(resource).length === 1 && resource.targets;
|
return Object.keys(resource).length === 1 && resource.targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ClientResourceSchema = z
|
export const PrivateResourceSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
mode: z.enum(["host", "cidr", "http"]),
|
mode: z.enum(["host", "cidr", "http"]),
|
||||||
@@ -435,19 +437,19 @@ export const ClientResourceSchema = z
|
|||||||
export const ConfigSchema = z
|
export const ConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
"proxy-resources": z
|
"proxy-resources": z
|
||||||
.record(z.string(), ResourceSchema)
|
.record(z.string(), PublicResourceSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
"public-resources": z
|
"public-resources": z
|
||||||
.record(z.string(), ResourceSchema)
|
.record(z.string(), PublicResourceSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
"client-resources": z
|
"client-resources": z
|
||||||
.record(z.string(), ClientResourceSchema)
|
.record(z.string(), PrivateResourceSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
"private-resources": z
|
"private-resources": z
|
||||||
.record(z.string(), ClientResourceSchema)
|
.record(z.string(), PrivateResourceSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
sites: z.record(z.string(), SiteSchema).optional().prefault({})
|
sites: z.record(z.string(), SiteSchema).optional().prefault({})
|
||||||
@@ -472,10 +474,13 @@ export const ConfigSchema = z
|
|||||||
}
|
}
|
||||||
|
|
||||||
return data as {
|
return data as {
|
||||||
"proxy-resources": Record<string, z.infer<typeof ResourceSchema>>;
|
"proxy-resources": Record<
|
||||||
|
string,
|
||||||
|
z.infer<typeof PublicResourceSchema>
|
||||||
|
>;
|
||||||
"client-resources": Record<
|
"client-resources": Record<
|
||||||
string,
|
string,
|
||||||
z.infer<typeof ClientResourceSchema>
|
z.infer<typeof PrivateResourceSchema>
|
||||||
>;
|
>;
|
||||||
sites: Record<string, z.infer<typeof SiteSchema>>;
|
sites: Record<string, z.infer<typeof SiteSchema>>;
|
||||||
};
|
};
|
||||||
@@ -614,5 +619,5 @@ export const ConfigSchema = z
|
|||||||
// Type inference from the schema
|
// Type inference from the schema
|
||||||
export type Site = z.infer<typeof SiteSchema>;
|
export type Site = z.infer<typeof SiteSchema>;
|
||||||
export type Target = z.infer<typeof TargetSchema>;
|
export type Target = z.infer<typeof TargetSchema>;
|
||||||
export type Resource = z.infer<typeof ResourceSchema>;
|
export type Resource = z.infer<typeof PublicResourceSchema>;
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
|||||||
@@ -32,3 +32,4 @@ export * from "./verifySiteResourceAccess";
|
|||||||
export * from "./logActionAudit";
|
export * from "./logActionAudit";
|
||||||
export * from "./verifyOlmAccess";
|
export * from "./verifyOlmAccess";
|
||||||
export * from "./verifyLimits";
|
export * from "./verifyLimits";
|
||||||
|
export * from "./verifyResourcePolicyAccess";
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ export * from "./verifyApiKeyClientAccess";
|
|||||||
export * from "./verifyApiKeySiteResourceAccess";
|
export * from "./verifyApiKeySiteResourceAccess";
|
||||||
export * from "./verifyApiKeyIdpAccess";
|
export * from "./verifyApiKeyIdpAccess";
|
||||||
export * from "./verifyApiKeyDomainAccess";
|
export * from "./verifyApiKeyDomainAccess";
|
||||||
|
export * from "./verifyApiKeyResourcePolicyAccess";
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
server/middlewares/verifyResourcePolicyAccess.ts
Normal file
127
server/middlewares/verifyResourcePolicyAccess.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
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";
|
||||||
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
|
||||||
|
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.userOrgRoleIds = await getUserOrgRoleIds(
|
||||||
|
req.userOrg.userId,
|
||||||
|
policy.orgId
|
||||||
|
);
|
||||||
|
req.userOrgId = policy.orgId;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error verifying resource policy access"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ export function verifyUserCanSetUserOrgRoles() {
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"User does not have permission perform this action"
|
"User does not have permission to set user organization roles"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export enum OpenAPITags {
|
|||||||
Org = "Organization",
|
Org = "Organization",
|
||||||
PublicResource = "Public Resource",
|
PublicResource = "Public Resource",
|
||||||
PrivateResource = "Private Resource",
|
PrivateResource = "Private Resource",
|
||||||
|
Policy = "Policy",
|
||||||
Role = "Role",
|
Role = "Role",
|
||||||
User = "User",
|
User = "User",
|
||||||
Invitation = "User Invitation",
|
Invitation = "User Invitation",
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
|
|||||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||||
import * as alertRule from "#private/routers/alertRule";
|
import * as alertRule from "#private/routers/alertRule";
|
||||||
import * as healthChecks from "#private/routers/healthChecks";
|
import * as healthChecks from "#private/routers/healthChecks";
|
||||||
|
import * as resource from "#private/routers/resource";
|
||||||
|
import * as policy from "#private/routers/policy";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -44,7 +46,8 @@ import {
|
|||||||
verifyUserCanSetUserOrgRoles,
|
verifyUserCanSetUserOrgRoles,
|
||||||
verifySiteProvisioningKeyAccess,
|
verifySiteProvisioningKeyAccess,
|
||||||
verifyIsLoggedInUser,
|
verifyIsLoggedInUser,
|
||||||
verifyAdmin
|
verifyAdmin,
|
||||||
|
verifyResourcePolicyAccess
|
||||||
} from "@server/middlewares";
|
} from "@server/middlewares";
|
||||||
import { ActionsEnum } from "@server/auth/actions";
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
import {
|
import {
|
||||||
@@ -382,6 +385,39 @@ authenticated.get(
|
|||||||
approval.countApprovals
|
approval.countApprovals
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/resource-policy/:resourcePolicyId",
|
||||||
|
verifyResourcePolicyAccess,
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.resourcePolicies),
|
||||||
|
verifyLimits,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteResourcePolicy),
|
||||||
|
logActionAudit(ActionsEnum.deleteResourcePolicy),
|
||||||
|
policy.deleteResourcePolicy
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/resource-policies",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.resourcePolicies),
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyUserHasAction(ActionsEnum.listResourcePolicies),
|
||||||
|
logActionAudit(ActionsEnum.listResourcePolicies),
|
||||||
|
policy.listResourcePolicies
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/resource-policy",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.resourcePolicies),
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyUserHasAction(ActionsEnum.createResourcePolicy),
|
||||||
|
logActionAudit(ActionsEnum.createResourcePolicy),
|
||||||
|
policy.createResourcePolicy
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/approvals/:approvalId",
|
"/org/:orgId/approvals/:approvalId",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
|||||||
@@ -45,8 +45,11 @@ import {
|
|||||||
users,
|
users,
|
||||||
userOrgs,
|
userOrgs,
|
||||||
roleResources,
|
roleResources,
|
||||||
|
rolePolicies,
|
||||||
userResources,
|
userResources,
|
||||||
|
userPolicies,
|
||||||
resourceRules,
|
resourceRules,
|
||||||
|
resourcePolicyRules,
|
||||||
userOrgRoles,
|
userOrgRoles,
|
||||||
roles
|
roles
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
@@ -430,7 +433,10 @@ hybridRouter.get(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Decrypt and save key file
|
// Decrypt and save key file
|
||||||
const decryptedKey = decrypt(cert.keyFile!, config.getRawConfig().server.secret!);
|
const decryptedKey = decrypt(
|
||||||
|
cert.keyFile!,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
|
||||||
// Return only the certificate data without org information
|
// Return only the certificate data without org information
|
||||||
return {
|
return {
|
||||||
@@ -531,7 +537,10 @@ hybridRouter.get(
|
|||||||
wildcardCandidates.length > 0
|
wildcardCandidates.length > 0
|
||||||
? and(
|
? and(
|
||||||
eq(resources.wildcard, true),
|
eq(resources.wildcard, true),
|
||||||
inArray(resources.fullDomain, wildcardCandidates)
|
inArray(
|
||||||
|
resources.fullDomain,
|
||||||
|
wildcardCandidates
|
||||||
|
)
|
||||||
)
|
)
|
||||||
: sql`false`
|
: sql`false`
|
||||||
)
|
)
|
||||||
@@ -545,10 +554,10 @@ hybridRouter.get(
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
result &&
|
result &&
|
||||||
await checkExitNodeOrg(
|
(await checkExitNodeOrg(
|
||||||
remoteExitNode.exitNodeId,
|
remoteExitNode.exitNodeId,
|
||||||
result.resources.orgId
|
result.resources.orgId
|
||||||
)
|
))
|
||||||
) {
|
) {
|
||||||
// If the exit node is not allowed for the org, return an error
|
// If the exit node is not allowed for the org, return an error
|
||||||
return next(
|
return next(
|
||||||
@@ -1132,22 +1141,43 @@ hybridRouter.get(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleResourceAccess = await db
|
const [direct, viaPolicies] = await Promise.all([
|
||||||
.select()
|
db
|
||||||
.from(roleResources)
|
.select()
|
||||||
.where(
|
.from(roleResources)
|
||||||
and(
|
.where(
|
||||||
eq(roleResources.resourceId, resourceId),
|
and(
|
||||||
eq(roleResources.roleId, roleId)
|
eq(roleResources.resourceId, resourceId),
|
||||||
|
eq(roleResources.roleId, roleId)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.limit(1),
|
||||||
.limit(1);
|
db
|
||||||
|
.select({
|
||||||
|
roleId: rolePolicies.roleId,
|
||||||
|
resourcePolicyId: rolePolicies.resourcePolicyId
|
||||||
|
})
|
||||||
|
.from(rolePolicies)
|
||||||
|
.innerJoin(
|
||||||
|
resources,
|
||||||
|
eq(
|
||||||
|
resources.resourcePolicyId,
|
||||||
|
rolePolicies.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
eq(rolePolicies.roleId, roleId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
]);
|
||||||
|
|
||||||
const result =
|
const result = direct[0] ?? viaPolicies[0] ?? null;
|
||||||
roleResourceAccess.length > 0 ? roleResourceAccess[0] : null;
|
|
||||||
|
|
||||||
return response<typeof roleResources.$inferSelect | null>(res, {
|
return response<typeof roleResources.$inferSelect | null>(res, {
|
||||||
data: result,
|
data: result as any,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: result
|
message: result
|
||||||
@@ -1222,21 +1252,44 @@ hybridRouter.get(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleResourceAccess = await db
|
const [direct, viaPolicies] = await Promise.all([
|
||||||
.select({
|
db
|
||||||
resourceId: roleResources.resourceId,
|
.select({
|
||||||
roleId: roleResources.roleId
|
resourceId: roleResources.resourceId,
|
||||||
})
|
roleId: roleResources.roleId
|
||||||
.from(roleResources)
|
})
|
||||||
.where(
|
.from(roleResources)
|
||||||
and(
|
.where(
|
||||||
eq(roleResources.resourceId, resourceId),
|
and(
|
||||||
inArray(roleResources.roleId, roleIds)
|
eq(roleResources.resourceId, resourceId),
|
||||||
)
|
inArray(roleResources.roleId, roleIds)
|
||||||
);
|
)
|
||||||
|
),
|
||||||
|
roleIds.length > 0
|
||||||
|
? db
|
||||||
|
.select({
|
||||||
|
resourceId: sql<number>`${resourceId}`,
|
||||||
|
roleId: rolePolicies.roleId
|
||||||
|
})
|
||||||
|
.from(rolePolicies)
|
||||||
|
.innerJoin(
|
||||||
|
resources,
|
||||||
|
eq(
|
||||||
|
resources.resourcePolicyId,
|
||||||
|
rolePolicies.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
inArray(rolePolicies.roleId, roleIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: Promise.resolve([])
|
||||||
|
]);
|
||||||
|
|
||||||
const result =
|
const combined = [...direct, ...viaPolicies];
|
||||||
roleResourceAccess.length > 0 ? roleResourceAccess : null;
|
const result = combined.length > 0 ? combined : null;
|
||||||
|
|
||||||
return response<{ resourceId: number; roleId: number }[] | null>(
|
return response<{ resourceId: number; roleId: number }[] | null>(
|
||||||
res,
|
res,
|
||||||
@@ -1397,10 +1450,45 @@ hybridRouter.get(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rules = await db
|
const [directRules, policyRules] = await Promise.all([
|
||||||
.select()
|
db
|
||||||
.from(resourceRules)
|
.select()
|
||||||
.where(eq(resourceRules.resourceId, resourceId));
|
.from(resourceRules)
|
||||||
|
.where(eq(resourceRules.resourceId, resourceId)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
ruleId: resourcePolicyRules.ruleId,
|
||||||
|
resourceId: sql<number>`${resourceId}`,
|
||||||
|
enabled: resourcePolicyRules.enabled,
|
||||||
|
priority: resourcePolicyRules.priority,
|
||||||
|
action: resourcePolicyRules.action,
|
||||||
|
match: resourcePolicyRules.match,
|
||||||
|
value: resourcePolicyRules.value
|
||||||
|
})
|
||||||
|
.from(resourcePolicyRules)
|
||||||
|
.innerJoin(
|
||||||
|
resources,
|
||||||
|
eq(
|
||||||
|
resources.resourcePolicyId,
|
||||||
|
resourcePolicyRules.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
]);
|
||||||
|
|
||||||
|
const maxDirectPriority = directRules.reduce(
|
||||||
|
(max, r) => Math.max(max, r.priority),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const offsetPolicyRules = policyRules.map((r) => ({
|
||||||
|
...r,
|
||||||
|
priority: maxDirectPriority + r.priority
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rules = [
|
||||||
|
...directRules,
|
||||||
|
...offsetPolicyRules
|
||||||
|
] as (typeof resourceRules.$inferSelect)[];
|
||||||
|
|
||||||
// backward compatibility: COUNTRY -> GEOIP
|
// backward compatibility: COUNTRY -> GEOIP
|
||||||
// TODO: remove this after a few versions once all exit nodes are updated
|
// TODO: remove this after a few versions once all exit nodes are updated
|
||||||
|
|||||||
417
server/private/routers/policy/createResourcePolicy.ts
Normal file
417
server/private/routers/policy/createResourcePolicy.ts
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 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 { hashPassword } from "@server/auth/password";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
idp,
|
||||||
|
idpOrg,
|
||||||
|
orgs,
|
||||||
|
resourcePolicies,
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
resourcePolicyPassword,
|
||||||
|
resourcePolicyPincode,
|
||||||
|
resourcePolicyRules,
|
||||||
|
resourcePolicyWhiteList,
|
||||||
|
rolePolicies,
|
||||||
|
roles,
|
||||||
|
userOrgs,
|
||||||
|
userPolicies,
|
||||||
|
users,
|
||||||
|
type ResourcePolicy
|
||||||
|
} from "@server/db";
|
||||||
|
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
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()
|
||||||
|
});
|
||||||
|
|
||||||
|
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),
|
||||||
|
// 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([]),
|
||||||
|
// 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({
|
||||||
|
method: "post",
|
||||||
|
path: "/org/{orgId}/resource-policy",
|
||||||
|
description: "Create a resource policy.",
|
||||||
|
tags: [OpenAPITags.Org, OpenAPITags.Policy],
|
||||||
|
request: {
|
||||||
|
params: createResourcePolicyParamsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: createResourcePolicyBodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createResourcePolicy(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Validate request params
|
||||||
|
const parsedParams = createResourcePolicyParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
if (req.user && req.userOrgRoleIds?.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the org
|
||||||
|
const org = await db
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (org.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Organization with ID ${orgId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = createResourcePolicyBodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
sso,
|
||||||
|
userIds,
|
||||||
|
roleIds,
|
||||||
|
skipToIdpId,
|
||||||
|
applyRules,
|
||||||
|
emailWhitelistEnabled,
|
||||||
|
password,
|
||||||
|
pincode,
|
||||||
|
headerAuth,
|
||||||
|
emails,
|
||||||
|
rules
|
||||||
|
} = parsedBody.data;
|
||||||
|
|
||||||
|
// 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 (!provider) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Identity provider not found in this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRoles = await db
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(inArray(roles.roleId, roleIds)));
|
||||||
|
|
||||||
|
const hasAdminRole = existingRoles.some((role) => role.isAdmin);
|
||||||
|
|
||||||
|
if (hasAdminRole) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Admin role cannot be assigned to resource policy"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUsers = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(userOrgs, eq(userOrgs.userId, users.userId))
|
||||||
|
.where(
|
||||||
|
and(eq(userOrgs.orgId, orgId), inArray(users.userId, userIds))
|
||||||
|
);
|
||||||
|
|
||||||
|
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)
|
||||||
|
.values({
|
||||||
|
niceId,
|
||||||
|
orgId,
|
||||||
|
name,
|
||||||
|
sso,
|
||||||
|
idpId: skipToIdpId,
|
||||||
|
applyRules,
|
||||||
|
emailWhitelistEnabled
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const rolesToAdd = [
|
||||||
|
{
|
||||||
|
roleId: adminRole[0].roleId,
|
||||||
|
resourcePolicyId: newPolicy.resourcePolicyId
|
||||||
|
}
|
||||||
|
] satisfies InferInsertModel<typeof rolePolicies>[];
|
||||||
|
|
||||||
|
rolesToAdd.push(
|
||||||
|
...existingRoles.map((role) => ({
|
||||||
|
roleId: role.roleId,
|
||||||
|
resourcePolicyId: newPolicy.resourcePolicyId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
await trx.insert(rolePolicies).values(rolesToAdd);
|
||||||
|
|
||||||
|
const usersToAdd: InferInsertModel<typeof userPolicies>[] = [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
req.user &&
|
||||||
|
!req.userOrgRoleIds?.includes(adminRole[0].roleId)
|
||||||
|
) {
|
||||||
|
// make sure the user can access the policy
|
||||||
|
usersToAdd.push({
|
||||||
|
userId: req.user?.userId!,
|
||||||
|
resourcePolicyId: newPolicy.resourcePolicyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
usersToAdd.push(
|
||||||
|
...existingUsers.map(({ user }) => ({
|
||||||
|
userId: user.userId,
|
||||||
|
resourcePolicyId: newPolicy.resourcePolicyId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (usersToAdd.length > 0) {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!policy) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to create policy"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response<ResourcePolicy>(res, {
|
||||||
|
data: policy,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "resource policy created successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
107
server/private/routers/policy/deleteResourcePolicy.ts
Normal file
107
server/private/routers/policy/deleteResourcePolicy.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 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, resources } 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-policy/{resourcePolicyId}",
|
||||||
|
description: "Delete a resource policy.",
|
||||||
|
tags: [OpenAPITags.Policy],
|
||||||
|
request: {
|
||||||
|
params: deleteResourcePolicySchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function deleteResourcePolicy(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
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 [existingResource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicies)
|
||||||
|
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||||
|
|
||||||
|
if (!existingResource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource Policy with ID ${resourcePolicyId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
server/private/routers/policy/index.ts
Normal file
16
server/private/routers/policy/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./createResourcePolicy";
|
||||||
|
export * from "./listResourcePolicies";
|
||||||
|
export * from "./deleteResourcePolicy";
|
||||||
271
server/private/routers/policy/listResourcePolicies.ts
Normal file
271
server/private/routers/policy/listResourcePolicies.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 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,
|
||||||
|
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,
|
||||||
|
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";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromZodError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const listResourcePoliciesParamsSchema = z.strictObject({
|
||||||
|
orgId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
const listResourcePoliciesSchema = z.object({
|
||||||
|
pageSize: z.coerce
|
||||||
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.catch(20)
|
||||||
|
.default(20)
|
||||||
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
default: 20,
|
||||||
|
description: "Number of items per page"
|
||||||
|
}),
|
||||||
|
page: z.coerce
|
||||||
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.optional()
|
||||||
|
.catch(1)
|
||||||
|
.default(1)
|
||||||
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
default: 1,
|
||||||
|
description: "Page number to retrieve"
|
||||||
|
}),
|
||||||
|
query: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
function queryResourcePoliciesBase() {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
resourcePolicyId: resourcePolicies.resourcePolicyId,
|
||||||
|
name: resourcePolicies.name,
|
||||||
|
niceId: resourcePolicies.niceId,
|
||||||
|
orgId: resourcePolicies.orgId
|
||||||
|
})
|
||||||
|
.from(resourcePolicies);
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/org/{orgId}/resource-policies",
|
||||||
|
description: "List resource policies for an organization.",
|
||||||
|
tags: [OpenAPITags.Org, OpenAPITags.Policy],
|
||||||
|
request: {
|
||||||
|
params: z.object({
|
||||||
|
orgId: z.string()
|
||||||
|
}),
|
||||||
|
query: listResourcePoliciesSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listResourcePolicies(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let accessibleResourcePolicies: Array<{ resourcePolicyId: number }>;
|
||||||
|
if (req.user) {
|
||||||
|
accessibleResourcePolicies = await db
|
||||||
|
.select({
|
||||||
|
resourcePolicyId: sql<number>`COALESCE(${userPolicies.resourcePolicyId}, ${rolePolicies.resourcePolicyId})`
|
||||||
|
})
|
||||||
|
.from(userPolicies)
|
||||||
|
.fullJoin(
|
||||||
|
rolePolicies,
|
||||||
|
eq(
|
||||||
|
userPolicies.resourcePolicyId,
|
||||||
|
rolePolicies.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
eq(userPolicies.userId, req.user!.userId),
|
||||||
|
inArray(rolePolicies.roleId, req.userOrgRoleIds || [])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} 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),
|
||||||
|
eq(resourcePolicies.scope, "global")
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
like(
|
||||||
|
sql`LOWER(${resourcePolicies.name})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${resourcePolicies.niceId})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
]);
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// avoids TS issues with reduce/never[]
|
||||||
|
const map = new Map<number, ResourcePolicyWithResources>();
|
||||||
|
|
||||||
|
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<ListResourcePoliciesResponse>(res, {
|
||||||
|
data: {
|
||||||
|
policies: policiesList,
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
pageSize,
|
||||||
|
page
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resources retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -671,7 +671,8 @@ export async function verifyResourceSession(
|
|||||||
resourceData.org
|
resourceData.org
|
||||||
);
|
);
|
||||||
|
|
||||||
localCache.set(userAccessCacheKey, allowedUserData, 5);
|
// this is query intensive so let it cache a little longer
|
||||||
|
localCache.set(userAccessCacheKey, allowedUserData, 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -1003,11 +1004,7 @@ async function checkRules(
|
|||||||
isIpInCidr(clientIp, rule.value)
|
isIpInCidr(clientIp, rule.value)
|
||||||
) {
|
) {
|
||||||
return rule.action as any;
|
return rule.action as any;
|
||||||
} else if (
|
} else if (clientIp && rule.match == "IP" && clientIp == rule.value) {
|
||||||
clientIp &&
|
|
||||||
rule.match == "IP" &&
|
|
||||||
clientIp == rule.value
|
|
||||||
) {
|
|
||||||
return rule.action as any;
|
return rule.action as any;
|
||||||
} else if (
|
} else if (
|
||||||
path &&
|
path &&
|
||||||
@@ -1015,10 +1012,7 @@ async function checkRules(
|
|||||||
isPathAllowed(rule.value, path)
|
isPathAllowed(rule.value, path)
|
||||||
) {
|
) {
|
||||||
return rule.action as any;
|
return rule.action as any;
|
||||||
} else if (
|
} else if (clientIp && rule.match == "COUNTRY") {
|
||||||
clientIp &&
|
|
||||||
rule.match == "COUNTRY"
|
|
||||||
) {
|
|
||||||
// COUNTRY=ALL should not affect local/private/CGNAT addresses.
|
// COUNTRY=ALL should not affect local/private/CGNAT addresses.
|
||||||
if (
|
if (
|
||||||
rule.value.toUpperCase() === "ALL" &&
|
rule.value.toUpperCase() === "ALL" &&
|
||||||
@@ -1030,10 +1024,7 @@ async function checkRules(
|
|||||||
if (await isIpInGeoIP(ipCC, rule.value)) {
|
if (await isIpInGeoIP(ipCC, rule.value)) {
|
||||||
return rule.action as any;
|
return rule.action as any;
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (clientIp && rule.match == "ASN") {
|
||||||
clientIp &&
|
|
||||||
rule.match == "ASN"
|
|
||||||
) {
|
|
||||||
// ASN=ALL/AS0 should not affect local/private/CGNAT addresses.
|
// ASN=ALL/AS0 should not affect local/private/CGNAT addresses.
|
||||||
if (
|
if (
|
||||||
(rule.value.toUpperCase() === "ALL" ||
|
(rule.value.toUpperCase() === "ALL" ||
|
||||||
@@ -1272,11 +1263,15 @@ export async function isIpInRegion(
|
|||||||
if (region.id === checkRegionCode) {
|
if (region.id === checkRegionCode) {
|
||||||
for (const subregion of region.includes) {
|
for (const subregion of region.includes) {
|
||||||
if (subregion.countries.includes(upperCode)) {
|
if (subregion.countries.includes(upperCode)) {
|
||||||
logger.debug(`Country ${upperCode} is in region ${region.id} (${region.name})`);
|
logger.debug(
|
||||||
|
`Country ${upperCode} is in region ${region.id} (${region.name})`
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.debug(`Country ${upperCode} is not in region ${region.id} (${region.name})`);
|
logger.debug(
|
||||||
|
`Country ${upperCode} is not in region ${region.id} (${region.name})`
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1284,10 +1279,14 @@ export async function isIpInRegion(
|
|||||||
for (const subregion of region.includes) {
|
for (const subregion of region.includes) {
|
||||||
if (subregion.id === checkRegionCode) {
|
if (subregion.id === checkRegionCode) {
|
||||||
if (subregion.countries.includes(upperCode)) {
|
if (subregion.countries.includes(upperCode)) {
|
||||||
logger.debug(`Country ${upperCode} is in region ${subregion.id} (${subregion.name})`);
|
logger.debug(
|
||||||
|
`Country ${upperCode} is in region ${subregion.id} (${subregion.name})`
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
logger.debug(`Country ${upperCode} is not in region ${subregion.id} (${subregion.name})`);
|
logger.debug(
|
||||||
|
`Country ${upperCode} is not in region ${subregion.id} (${subregion.name})`
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import config from "@server/lib/config";
|
|||||||
import * as site from "./site";
|
import * as site from "./site";
|
||||||
import * as org from "./org";
|
import * as org from "./org";
|
||||||
import * as resource from "./resource";
|
import * as resource from "./resource";
|
||||||
|
import * as policy from "./policy";
|
||||||
import * as domain from "./domain";
|
import * as domain from "./domain";
|
||||||
import * as target from "./target";
|
import * as target from "./target";
|
||||||
import * as user from "./user";
|
import * as user from "./user";
|
||||||
@@ -42,7 +43,8 @@ import {
|
|||||||
verifyUserIsOrgOwner,
|
verifyUserIsOrgOwner,
|
||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
verifyOlmAccess,
|
verifyOlmAccess,
|
||||||
verifyLimits
|
verifyLimits,
|
||||||
|
verifyResourcePolicyAccess
|
||||||
} from "@server/middlewares";
|
} from "@server/middlewares";
|
||||||
import { ActionsEnum } from "@server/auth/actions";
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
||||||
@@ -103,7 +105,6 @@ authenticated.put(
|
|||||||
site.createSite
|
site.createSite
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/sites",
|
"/org/:orgId/sites",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -540,6 +541,7 @@ authenticated.get(
|
|||||||
verifyUserHasAction(ActionsEnum.getResource),
|
verifyUserHasAction(ActionsEnum.getResource),
|
||||||
resource.getResource
|
resource.getResource
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/resource/:resourceId",
|
"/resource/:resourceId",
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
@@ -646,6 +648,29 @@ authenticated.post(
|
|||||||
logActionAudit(ActionsEnum.updateRole),
|
logActionAudit(ActionsEnum.updateRole),
|
||||||
role.updateRole
|
role.updateRole
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/resource-policy/:niceId",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyResourcePolicyAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getResourcePolicy),
|
||||||
|
policy.getResourcePolicy
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/resource/:resourceId/policies",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getResourcePolicy),
|
||||||
|
resource.getResourcePolicies
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/resource-policy/:resourcePolicyId",
|
||||||
|
verifyResourcePolicyAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.updateResourcePolicy),
|
||||||
|
policy.updateResourcePolicy
|
||||||
|
);
|
||||||
|
|
||||||
// authenticated.get(
|
// authenticated.get(
|
||||||
// "/role/:roleId",
|
// "/role/:roleId",
|
||||||
// verifyRoleAccess,
|
// verifyRoleAccess,
|
||||||
@@ -697,6 +722,59 @@ authenticated.post(
|
|||||||
resource.setResourceUsers
|
resource.setResourceUsers
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/resource-policy/:resourcePolicyId/access-control",
|
||||||
|
verifyResourcePolicyAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.setResourcePolicyUsers),
|
||||||
|
logActionAudit(ActionsEnum.setResourcePolicyUsers),
|
||||||
|
policy.setResourcePolicyAccessControl
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/resource-policy/:resourcePolicyId/password",
|
||||||
|
verifyResourcePolicyAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyUserHasAction(ActionsEnum.setResourcePolicyPassword),
|
||||||
|
logActionAudit(ActionsEnum.setResourcePolicyPassword),
|
||||||
|
policy.setResourcePolicyPassword
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/resource-policy/:resourcePolicyId/pincode",
|
||||||
|
verifyResourcePolicyAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyUserHasAction(ActionsEnum.setResourcePolicyPincode),
|
||||||
|
logActionAudit(ActionsEnum.setResourcePolicyPincode),
|
||||||
|
policy.setResourcePolicyPincode
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/resource-policy/:resourcePolicyId/header-auth",
|
||||||
|
verifyResourcePolicyAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyUserHasAction(ActionsEnum.setResourcePolicyHeaderAuth),
|
||||||
|
logActionAudit(ActionsEnum.setResourcePolicyHeaderAuth),
|
||||||
|
policy.setResourcePolicyHeaderAuth
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/resource-policy/:resourcePolicyId/whitelist",
|
||||||
|
verifyResourcePolicyAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyUserHasAction(ActionsEnum.setResourcePolicyWhitelist),
|
||||||
|
logActionAudit(ActionsEnum.setResourcePolicyWhitelist),
|
||||||
|
policy.setResourcePolicyWhitelist
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/resource-policy/:resourcePolicyId/rules",
|
||||||
|
verifyResourcePolicyAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyUserHasAction(ActionsEnum.setResourcePolicyRules),
|
||||||
|
logActionAudit(ActionsEnum.setResourcePolicyRules),
|
||||||
|
policy.setResourcePolicyRules
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/resource/:resourceId/password`,
|
`/resource/:resourceId/password`,
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as site from "./site";
|
|||||||
import * as org from "./org";
|
import * as org from "./org";
|
||||||
import * as blueprints from "./blueprints";
|
import * as blueprints from "./blueprints";
|
||||||
import * as resource from "./resource";
|
import * as resource from "./resource";
|
||||||
|
import * as policy from "./policy";
|
||||||
import * as domain from "./domain";
|
import * as domain from "./domain";
|
||||||
import * as target from "./target";
|
import * as target from "./target";
|
||||||
import * as user from "./user";
|
import * as user from "./user";
|
||||||
@@ -29,7 +30,9 @@ import {
|
|||||||
verifyApiKeySiteResourceAccess,
|
verifyApiKeySiteResourceAccess,
|
||||||
verifyApiKeySetResourceClients,
|
verifyApiKeySetResourceClients,
|
||||||
verifyLimits,
|
verifyLimits,
|
||||||
verifyApiKeyDomainAccess
|
verifyApiKeyDomainAccess,
|
||||||
|
verifyApiKeyResourcePolicyAccess,
|
||||||
|
verifyUserHasAction
|
||||||
} from "@server/middlewares";
|
} from "@server/middlewares";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
@@ -459,6 +462,20 @@ authenticated.get(
|
|||||||
resource.getResource
|
resource.getResource
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/resource-policy/:resourcePolicyId",
|
||||||
|
verifyApiKeyResourcePolicyAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.getResourcePolicy),
|
||||||
|
policy.getResourcePolicy
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/resource/:resourceId/policies",
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.getResourcePolicy),
|
||||||
|
resource.getResourcePolicies
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/resource/:resourceId",
|
"/resource/:resourceId",
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
@@ -468,6 +485,13 @@ authenticated.post(
|
|||||||
resource.updateResource
|
resource.updateResource
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/resource-policy/:resourcePolicyId",
|
||||||
|
verifyApiKeyResourcePolicyAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.updateResourcePolicy),
|
||||||
|
policy.updateResourcePolicy
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/resource/:resourceId",
|
"/resource/:resourceId",
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
@@ -619,6 +643,63 @@ authenticated.post(
|
|||||||
resource.setResourceUsers
|
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.put(
|
||||||
|
"/resource-policy/:resourcePolicyId/password",
|
||||||
|
verifyApiKeyResourcePolicyAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPassword),
|
||||||
|
logActionAudit(ActionsEnum.setResourcePolicyPassword),
|
||||||
|
policy.setResourcePolicyPassword
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/resource-policy/:resourcePolicyId/pincode",
|
||||||
|
verifyApiKeyResourcePolicyAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPincode),
|
||||||
|
logActionAudit(ActionsEnum.setResourcePolicyPincode),
|
||||||
|
policy.setResourcePolicyPincode
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/resource-policy/:resourcePolicyId/header-auth",
|
||||||
|
verifyApiKeyResourcePolicyAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyHeaderAuth),
|
||||||
|
logActionAudit(ActionsEnum.setResourcePolicyHeaderAuth),
|
||||||
|
policy.setResourcePolicyHeaderAuth
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/resource-policy/:resourcePolicyId/whitelist",
|
||||||
|
verifyApiKeyResourcePolicyAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyWhitelist),
|
||||||
|
logActionAudit(ActionsEnum.setResourcePolicyWhitelist),
|
||||||
|
policy.setResourcePolicyWhitelist
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/resource-policy/:resourcePolicyId/rules",
|
||||||
|
verifyApiKeyResourcePolicyAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRules),
|
||||||
|
logActionAudit(ActionsEnum.setResourcePolicyRules),
|
||||||
|
policy.setResourcePolicyRules
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/resource/:resourceId/roles/add",
|
"/resource/:resourceId/roles/add",
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
|
|||||||
231
server/routers/policy/getResourcePolicy.ts
Normal file
231
server/routers/policy/getResourcePolicy.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import {
|
||||||
|
db,
|
||||||
|
idp,
|
||||||
|
resourcePolicyRules,
|
||||||
|
resourcePolicies,
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
resourcePolicyPassword,
|
||||||
|
resourcePolicyPincode,
|
||||||
|
resourcePolicyWhiteList,
|
||||||
|
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, isNull, not, or, 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<string>()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
description: "Resource policy ID"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function queryResourcePolicy(
|
||||||
|
params: z.infer<typeof getResourcePolicySchema>
|
||||||
|
) {
|
||||||
|
const conditions: SQL<unknown>[] = [];
|
||||||
|
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({
|
||||||
|
resourcePolicyId: resourcePolicies.resourcePolicyId,
|
||||||
|
sso: resourcePolicies.sso,
|
||||||
|
applyRules: resourcePolicies.applyRules,
|
||||||
|
emailWhitelistEnabled: resourcePolicies.emailWhitelistEnabled,
|
||||||
|
idpId: resourcePolicies.idpId,
|
||||||
|
niceId: resourcePolicies.niceId,
|
||||||
|
name: resourcePolicies.name,
|
||||||
|
passwordId: resourcePolicyPassword.passwordId,
|
||||||
|
pincodeId: resourcePolicyPincode.pincodeId,
|
||||||
|
headerAuth: {
|
||||||
|
id: resourcePolicyHeaderAuth.headerAuthId,
|
||||||
|
extendedCompability:
|
||||||
|
resourcePolicyHeaderAuth.extendedCompatibility
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.from(resourcePolicies)
|
||||||
|
.leftJoin(
|
||||||
|
resourcePolicyPassword,
|
||||||
|
eq(
|
||||||
|
resourcePolicyPassword.resourcePolicyId,
|
||||||
|
resourcePolicies.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
resourcePolicyPincode,
|
||||||
|
eq(
|
||||||
|
resourcePolicyPincode.resourcePolicyId,
|
||||||
|
resourcePolicies.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
eq(
|
||||||
|
resourcePolicyHeaderAuth.resourcePolicyId,
|
||||||
|
resourcePolicies.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
const policyEmailWhiteList = await db
|
||||||
|
.select({
|
||||||
|
whiteListId: resourcePolicyWhiteList.whitelistId,
|
||||||
|
email: resourcePolicyWhiteList.email
|
||||||
|
})
|
||||||
|
.from(resourcePolicyWhiteList)
|
||||||
|
.where(
|
||||||
|
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,
|
||||||
|
rules: policyRules
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetResourcePolicyResponse = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof queryResourcePolicy>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
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<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = getResourcePolicySchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = await queryResourcePolicy(parsedParams.data);
|
||||||
|
|
||||||
|
if (!policy) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource policy not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<GetResourcePolicyResponse>(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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
server/routers/policy/index.ts
Normal file
8
server/routers/policy/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export * from "./getResourcePolicy";
|
||||||
|
export * from "./updateResourcePolicy";
|
||||||
|
export * from "./setResourcePolicyAccessControl";
|
||||||
|
export * from "./setResourcePolicyPassword";
|
||||||
|
export * from "./setResourcePolicyPincode";
|
||||||
|
export * from "./setResourcePolicyHeaderAuth";
|
||||||
|
export * from "./setResourcePolicyWhitelist";
|
||||||
|
export * from "./setResourcePolicyRules";
|
||||||
237
server/routers/policy/setResourcePolicyAccessControl.ts
Normal file
237
server/routers/policy/setResourcePolicyAccessControl.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
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 { 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()).openapi({
|
||||||
|
type: "array"
|
||||||
|
}),
|
||||||
|
skipToIdpId: z.int().positive().optional().nullable().openapi({
|
||||||
|
type: "integer",
|
||||||
|
description: "Page number to retrieve"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
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, roles, Identity provider.",
|
||||||
|
tags: [OpenAPITags.Policy, OpenAPITags.User],
|
||||||
|
request: {
|
||||||
|
params: setResourcePolicyAccessControlParamsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: setResourcePolicyAcccessControlBodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function setResourcePolicyAccessControl(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedBody = setResourcePolicyAcccessControlBodySchema.safeParse(
|
||||||
|
req.body
|
||||||
|
);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userIds, roleIds, sso, skipToIdpId: idpId } = parsedBody.data;
|
||||||
|
|
||||||
|
const parsedParams =
|
||||||
|
setResourcePolicyAccessControlParamsSchema.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.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: "Resource policy succesfully updated",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
server/routers/policy/setResourcePolicyHeaderAuth.ts
Normal file
117
server/routers/policy/setResourcePolicyHeaderAuth.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, resourcePolicyHeaderAuth } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { response } from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const setResourcePolicyHeaderAuthParamsSchema = z.object({
|
||||||
|
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
const setResourcePolicyHeaderAuthBodySchema = z.strictObject({
|
||||||
|
headerAuth: z
|
||||||
|
.object({
|
||||||
|
user: z.string().min(4).max(100),
|
||||||
|
password: z.string().min(4).max(100),
|
||||||
|
extendedCompatibility: z.boolean()
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "put",
|
||||||
|
path: "/resource-policy/{resourcePolicyId}/header-auth",
|
||||||
|
description:
|
||||||
|
"Set or update the header authentication for a resource policy. If user and password is not provided, it will remove the header authentication.",
|
||||||
|
tags: [OpenAPITags.Policy],
|
||||||
|
request: {
|
||||||
|
params: setResourcePolicyHeaderAuthParamsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: setResourcePolicyHeaderAuthBodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function setResourcePolicyHeaderAuth(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = setResourcePolicyHeaderAuthParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = setResourcePolicyHeaderAuthBodySchema.safeParse(
|
||||||
|
req.body
|
||||||
|
);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourcePolicyId } = parsedParams.data;
|
||||||
|
const { headerAuth } = parsedBody.data;
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyHeaderAuth)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePolicyHeaderAuth.resourcePolicyId,
|
||||||
|
resourcePolicyId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (headerAuth !== null) {
|
||||||
|
const headerAuthHash = await hashPassword(
|
||||||
|
Buffer.from(
|
||||||
|
`${headerAuth.user}:${headerAuth.password}`
|
||||||
|
).toString("base64")
|
||||||
|
);
|
||||||
|
|
||||||
|
await trx.insert(resourcePolicyHeaderAuth).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
headerAuthHash,
|
||||||
|
extendedCompatibility: headerAuth.extendedCompatibility
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Header Authentication set successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
server/routers/policy/setResourcePolicyPassword.ts
Normal file
106
server/routers/policy/setResourcePolicyPassword.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { resourcePolicyPassword } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { response } from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const setResourcePolicyPasswordParamsSchema = z.object({
|
||||||
|
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
const setResourcePolicyPasswordBodySchema = z.strictObject({
|
||||||
|
password: z.string().min(4).max(100).nullable()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "put",
|
||||||
|
path: "/resource-policy/{resourcePolicyId}/password",
|
||||||
|
description:
|
||||||
|
"Set the password for a resource policy. Setting the password to null will remove it.",
|
||||||
|
tags: [OpenAPITags.Policy],
|
||||||
|
request: {
|
||||||
|
params: setResourcePolicyPasswordParamsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: setResourcePolicyPasswordBodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function setResourcePolicyPassword(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = setResourcePolicyPasswordParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = setResourcePolicyPasswordBodySchema.safeParse(
|
||||||
|
req.body
|
||||||
|
);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourcePolicyId } = parsedParams.data;
|
||||||
|
const { password } = parsedBody.data;
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyPassword)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePolicyPassword.resourcePolicyId,
|
||||||
|
resourcePolicyId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.insert(resourcePolicyPassword)
|
||||||
|
.values({ resourcePolicyId, passwordHash });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource policy password set successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
server/routers/policy/setResourcePolicyPincode.ts
Normal file
106
server/routers/policy/setResourcePolicyPincode.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { resourcePolicyPincode } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { response } from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const setResourcePolicyPincodeParamsSchema = z.object({
|
||||||
|
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
const setResourcePolicyPincodeBodySchema = z.strictObject({
|
||||||
|
pincode: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{6}$/)
|
||||||
|
.or(z.null())
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "put",
|
||||||
|
path: "/resource-policy/{resourcePolicyId}/pincode",
|
||||||
|
description:
|
||||||
|
"Set the PIN code for a resource policy. Setting the PIN code to null will remove it.",
|
||||||
|
tags: [OpenAPITags.Policy],
|
||||||
|
request: {
|
||||||
|
params: setResourcePolicyPincodeParamsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: setResourcePolicyPincodeBodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function setResourcePolicyPincode(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = setResourcePolicyPincodeParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = setResourcePolicyPincodeBodySchema.safeParse(
|
||||||
|
req.body
|
||||||
|
);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourcePolicyId } = parsedParams.data;
|
||||||
|
const { pincode } = parsedBody.data;
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyPincode)
|
||||||
|
.where(
|
||||||
|
eq(resourcePolicyPincode.resourcePolicyId, resourcePolicyId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pincode) {
|
||||||
|
const pincodeHash = await hashPassword(pincode);
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.insert(resourcePolicyPincode)
|
||||||
|
.values({ resourcePolicyId, pincodeHash, digitLength: 6 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource policy PIN code set successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
server/routers/policy/setResourcePolicyRules.ts
Normal file
167
server/routers/policy/setResourcePolicyRules.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, resourcePolicyRules, 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().openapi({
|
||||||
|
type: "integer",
|
||||||
|
description: "Rule priority"
|
||||||
|
}),
|
||||||
|
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<any> {
|
||||||
|
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(resourcePolicyRules)
|
||||||
|
.where(
|
||||||
|
eq(resourcePolicyRules.resourcePolicyId, resourcePolicyId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rules.length > 0) {
|
||||||
|
await trx.insert(resourcePolicyRules).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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
132
server/routers/policy/setResourcePolicyWhitelist.ts
Normal file
132
server/routers/policy/setResourcePolicyWhitelist.ts
Normal file
@@ -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.Policy],
|
||||||
|
request: {
|
||||||
|
params: setResourcePolicyWhitelistParamsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: setResourcePolicyWhitelistBodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function setResourcePolicyWhitelist(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
157
server/routers/policy/updateResourcePolicy.ts
Normal file
157
server/routers/policy/updateResourcePolicy.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
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 { 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";
|
||||||
|
|
||||||
|
const updateResourcePolicyParamsSchema = z.strictObject({
|
||||||
|
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateResourcePolicyBodySchema = z.strictObject({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
niceId: z.string().min(1).max(255).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "put",
|
||||||
|
path: "/resource-policy/{resourcePolicyId}",
|
||||||
|
description: "Update a resource policy.",
|
||||||
|
tags: [OpenAPITags.Org, OpenAPITags.Policy],
|
||||||
|
request: {
|
||||||
|
params: updateResourcePolicyParamsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: updateResourcePolicyBodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateResourcePolicy(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const parsedParams = updateResourcePolicyParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.user && req.userOrgRoleIds?.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourcePolicyId } = parsedParams.data;
|
||||||
|
const [result] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicies)
|
||||||
|
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||||
|
.leftJoin(orgs, eq(resourcePolicies.orgId, orgs.orgId));
|
||||||
|
|
||||||
|
const policy = result?.resourcePolicies;
|
||||||
|
const org = result?.orgs;
|
||||||
|
|
||||||
|
if (!policy || !org) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource Policy with ID ${resourcePolicyId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = updateResourcePolicyBodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = parsedBody.data;
|
||||||
|
|
||||||
|
if (updateData.niceId) {
|
||||||
|
const [existingPolicy] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourcePolicies.niceId, updateData.niceId),
|
||||||
|
eq(resourcePolicies.orgId, policy.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
existingPolicy &&
|
||||||
|
existingPolicy.resourcePolicyId !== policy.resourcePolicyId
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
`A resource policy with niceId "${updateData.niceId}" already exists`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedPolicy = await db.transaction(async (trx) => {
|
||||||
|
const [updated] = await trx
|
||||||
|
.update(resourcePolicies)
|
||||||
|
.set({
|
||||||
|
...updateData
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePolicies.resourcePolicyId,
|
||||||
|
policy.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedPolicy) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to update policy"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<ResourcePolicy>(res, {
|
||||||
|
data: updatedPolicy,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource policy updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, domainNamespaces, loginPage } from "@server/db";
|
import { build } from "@server/build";
|
||||||
import {
|
import {
|
||||||
domains,
|
db,
|
||||||
orgDomains,
|
loginPage,
|
||||||
orgs,
|
orgs,
|
||||||
Resource,
|
Resource,
|
||||||
resources,
|
resources,
|
||||||
|
resourcePolicies,
|
||||||
roleResources,
|
roleResources,
|
||||||
|
rolePolicies,
|
||||||
roles,
|
roles,
|
||||||
userResources
|
userPolicies,
|
||||||
|
userResources,
|
||||||
|
domainNamespaces
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -20,13 +24,18 @@ import logger from "@server/logger";
|
|||||||
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
|
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { build } from "@server/build";
|
|
||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||||
import { getUniqueResourceName } from "@server/db/names";
|
import {
|
||||||
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
|
validateAndConstructDomain,
|
||||||
|
checkWildcardDomainConflict
|
||||||
|
} from "@server/lib/domainUtils";
|
||||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import {
|
||||||
|
getUniqueResourceName,
|
||||||
|
getUniqueResourcePolicyName
|
||||||
|
} from "@server/db/names";
|
||||||
|
|
||||||
const createResourceParamsSchema = z.strictObject({
|
const createResourceParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -311,8 +320,46 @@ async function createHttpResource(
|
|||||||
let resource: Resource | undefined;
|
let resource: Resource | undefined;
|
||||||
|
|
||||||
const niceId = await getUniqueResourceName(orgId);
|
const niceId = await getUniqueResourceName(orgId);
|
||||||
|
const policyNiceId = await getUniqueResourcePolicyName(orgId);
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
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.userOrgRoleIds?.includes(adminRole[0].roleId)) {
|
||||||
|
await trx.insert(userPolicies).values({
|
||||||
|
userId: req.user?.userId!,
|
||||||
|
resourcePolicyId: defaultPolicy.resourcePolicyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const newResource = await trx
|
const newResource = await trx
|
||||||
.insert(resources)
|
.insert(resources)
|
||||||
.values({
|
.values({
|
||||||
@@ -328,22 +375,11 @@ async function createHttpResource(
|
|||||||
stickySession: stickySession,
|
stickySession: stickySession,
|
||||||
postAuthPath: postAuthPath,
|
postAuthPath: postAuthPath,
|
||||||
wildcard,
|
wildcard,
|
||||||
health: "unknown"
|
health: "unknown",
|
||||||
|
defaultResourcePolicyId: defaultPolicy.resourcePolicyId
|
||||||
})
|
})
|
||||||
.returning();
|
.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({
|
await trx.insert(roleResources).values({
|
||||||
roleId: adminRole[0].roleId,
|
roleId: adminRole[0].roleId,
|
||||||
resourceId: newResource[0].resourceId
|
resourceId: newResource[0].resourceId
|
||||||
@@ -369,7 +405,7 @@ async function createHttpResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (build != "oss") {
|
if (build !== "oss") {
|
||||||
await createCertificate(domainId, fullDomain, db);
|
await createCertificate(domainId, fullDomain, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,22 +446,10 @@ async function createRawResource(
|
|||||||
let resource: Resource | undefined;
|
let resource: Resource | undefined;
|
||||||
|
|
||||||
const niceId = await getUniqueResourceName(orgId);
|
const niceId = await getUniqueResourceName(orgId);
|
||||||
|
const policyNiceId = await getUniqueResourcePolicyName(orgId);
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const newResource = await trx
|
const adminRole = await trx
|
||||||
.insert(resources)
|
|
||||||
.values({
|
|
||||||
niceId,
|
|
||||||
orgId,
|
|
||||||
name,
|
|
||||||
http,
|
|
||||||
protocol,
|
|
||||||
proxyPort
|
|
||||||
// enableProxy
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const adminRole = await db
|
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||||
@@ -437,6 +461,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.userOrgRoleIds?.includes(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({
|
await trx.insert(roleResources).values({
|
||||||
roleId: adminRole[0].roleId,
|
roleId: adminRole[0].roleId,
|
||||||
resourceId: newResource[0].resourceId
|
resourceId: newResource[0].resourceId
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { db, targetHealthCheck } from "@server/db";
|
|
||||||
import { newts, resources, sites, targets } from "@server/db";
|
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
newts,
|
||||||
|
resourcePolicies,
|
||||||
|
resources,
|
||||||
|
sites,
|
||||||
|
targetHealthCheck,
|
||||||
|
targets
|
||||||
|
} from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import logger from "@server/logger";
|
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 { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { removeTargets } from "../newt/targets";
|
||||||
|
|
||||||
// Define Zod schema for request parameters validation
|
// Define Zod schema for request parameters validation
|
||||||
const deleteResourceSchema = z.strictObject({
|
const deleteResourceSchema = z.strictObject({
|
||||||
@@ -113,6 +118,18 @@ export async function deleteResource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also delete default resource policy
|
||||||
|
if (deletedResource.defaultResourcePolicyId) {
|
||||||
|
await db
|
||||||
|
.delete(resourcePolicies)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePolicies.resourcePolicyId,
|
||||||
|
deletedResource.defaultResourcePolicyId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { Request, Response, NextFunction } from "express";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
resourceHeaderAuth,
|
resourcePolicies,
|
||||||
resourceHeaderAuthExtendedCompatibility,
|
resourcePolicyHeaderAuth,
|
||||||
resourcePassword,
|
resourcePolicyPassword,
|
||||||
resourcePincode,
|
resourcePolicyPincode,
|
||||||
resources
|
resources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, or } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -60,64 +60,53 @@ export async function getResourceAuthInfo(
|
|||||||
|
|
||||||
const isGuidInteger = /^\d+$/.test(resourceGuid);
|
const isGuidInteger = /^\d+$/.test(resourceGuid);
|
||||||
|
|
||||||
|
const buildQuery = (whereClause: ReturnType<typeof eq>) =>
|
||||||
|
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] =
|
const [result] =
|
||||||
isGuidInteger && build === "saas"
|
isGuidInteger && build === "saas"
|
||||||
? await db
|
? await buildQuery(
|
||||||
.select()
|
eq(resources.resourceId, Number(resourceGuid))
|
||||||
.from(resources)
|
)
|
||||||
.leftJoin(
|
: await buildQuery(eq(resources.resourceGuid, resourceGuid));
|
||||||
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);
|
|
||||||
|
|
||||||
const resource = result?.resources;
|
const resource = result?.resources;
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
@@ -126,11 +115,10 @@ export async function getResourceAuthInfo(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pincode = result?.resourcePincode;
|
const policy = result?.resourcePolicies;
|
||||||
const password = result?.resourcePassword;
|
const pincode = result?.resourcePolicyPincode;
|
||||||
const headerAuth = result?.resourceHeaderAuth;
|
const password = result?.resourcePolicyPassword;
|
||||||
const headerAuthExtendedCompatibility =
|
const headerAuth = result?.resourcePolicyHeaderAuth;
|
||||||
result?.resourceHeaderAuthExtendedCompatibility;
|
|
||||||
|
|
||||||
const url = resource.fullDomain
|
const url = resource.fullDomain
|
||||||
? `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
|
? `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
|
||||||
@@ -146,13 +134,13 @@ export async function getResourceAuthInfo(
|
|||||||
pincode: pincode !== null,
|
pincode: pincode !== null,
|
||||||
headerAuth: headerAuth !== null,
|
headerAuth: headerAuth !== null,
|
||||||
headerAuthExtendedCompatibility:
|
headerAuthExtendedCompatibility:
|
||||||
headerAuthExtendedCompatibility !== null,
|
headerAuth?.extendedCompatibility ?? false,
|
||||||
sso: resource.sso,
|
sso: policy?.sso ?? false,
|
||||||
blockAccess: resource.blockAccess,
|
blockAccess: resource.blockAccess,
|
||||||
url: url ?? "",
|
url: url ?? "",
|
||||||
wildcard: resource.wildcard ?? false,
|
wildcard: resource.wildcard ?? false,
|
||||||
fullDomain: resource.fullDomain,
|
fullDomain: resource.fullDomain,
|
||||||
whitelist: resource.emailWhitelistEnabled,
|
whitelist: policy?.emailWhitelistEnabled ?? false,
|
||||||
skipToIdpId: resource.skipToIdpId,
|
skipToIdpId: resource.skipToIdpId,
|
||||||
orgId: resource.orgId,
|
orgId: resource.orgId,
|
||||||
postAuthPath: resource.postAuthPath ?? null
|
postAuthPath: resource.postAuthPath ?? null
|
||||||
|
|||||||
109
server/routers/resource/getResourcePolicies.ts
Normal file
109
server/routers/resource/getResourcePolicies.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
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;
|
||||||
|
sharedPolicy: GetResourcePolicyResponse | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/resource/{resourceId}/policies",
|
||||||
|
description: "Get the inline and shared policies associated with a resource.",
|
||||||
|
tags: [OpenAPITags.PublicResource, OpenAPITags.Policy],
|
||||||
|
request: {
|
||||||
|
params: getResourcePoliciesParamsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getResourcePolicies(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
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,
|
||||||
|
resourcePolicyId: resources.resourcePolicyId
|
||||||
|
})
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resource.defaultResourcePolicyId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"Resource has no default policy"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [defaultPolicy, sharedPolicy] = await Promise.all([
|
||||||
|
queryResourcePolicy({
|
||||||
|
resourcePolicyId: resource.defaultResourcePolicyId
|
||||||
|
}),
|
||||||
|
resource.resourcePolicyId
|
||||||
|
? queryResourcePolicy({
|
||||||
|
resourcePolicyId: resource.resourcePolicyId
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response<GetResourcePoliciesResponse>(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",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,3 +33,4 @@ export * from "./removeUserFromResource";
|
|||||||
export * from "./listAllResourceNames";
|
export * from "./listAllResourceNames";
|
||||||
export * from "./removeEmailFromResourceWhitelist";
|
export * from "./removeEmailFromResourceWhitelist";
|
||||||
export * from "./getStatusHistory";
|
export * from "./getStatusHistory";
|
||||||
|
export * from "./getResourcePolicies";
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
resourceHeaderAuth,
|
resourcePolicies,
|
||||||
resourceHeaderAuthExtendedCompatibility,
|
resourcePolicyHeaderAuth,
|
||||||
resourcePassword,
|
resourcePolicyPassword,
|
||||||
resourcePincode,
|
resourcePolicyPincode,
|
||||||
resources,
|
resources,
|
||||||
roleResources,
|
roleResources,
|
||||||
sites,
|
sites,
|
||||||
@@ -163,10 +163,10 @@ function queryResourcesBase() {
|
|||||||
name: resources.name,
|
name: resources.name,
|
||||||
ssl: resources.ssl,
|
ssl: resources.ssl,
|
||||||
fullDomain: resources.fullDomain,
|
fullDomain: resources.fullDomain,
|
||||||
passwordId: resourcePassword.passwordId,
|
passwordId: resourcePolicyPassword.passwordId,
|
||||||
sso: resources.sso,
|
sso: resourcePolicies.sso,
|
||||||
pincodeId: resourcePincode.pincodeId,
|
pincodeId: resourcePolicyPincode.pincodeId,
|
||||||
whitelist: resources.emailWhitelistEnabled,
|
whitelist: resourcePolicies.emailWhitelistEnabled,
|
||||||
http: resources.http,
|
http: resources.http,
|
||||||
protocol: resources.protocol,
|
protocol: resources.protocol,
|
||||||
proxyPort: resources.proxyPort,
|
proxyPort: resources.proxyPort,
|
||||||
@@ -174,29 +174,45 @@ function queryResourcesBase() {
|
|||||||
domainId: resources.domainId,
|
domainId: resources.domainId,
|
||||||
niceId: resources.niceId,
|
niceId: resources.niceId,
|
||||||
wildcard: resources.wildcard,
|
wildcard: resources.wildcard,
|
||||||
headerAuthId: resourceHeaderAuth.headerAuthId,
|
health: resources.health,
|
||||||
headerAuthExtendedCompatibilityId:
|
headerAuthId: resourcePolicyHeaderAuth.headerAuthId,
|
||||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
|
headerAuthExtendedCompatibility:
|
||||||
health: resources.health
|
resourcePolicyHeaderAuth.extendedCompatibility
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
resourcePassword,
|
resourcePolicies,
|
||||||
eq(resourcePassword.resourceId, resources.resourceId)
|
or(
|
||||||
|
eq(
|
||||||
|
resourcePolicies.resourcePolicyId,
|
||||||
|
resources.resourcePolicyId
|
||||||
|
),
|
||||||
|
eq(
|
||||||
|
resourcePolicies.resourcePolicyId,
|
||||||
|
resources.defaultResourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
resourcePincode,
|
resourcePolicyPassword,
|
||||||
eq(resourcePincode.resourceId, resources.resourceId)
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
resourceHeaderAuth,
|
|
||||||
eq(resourceHeaderAuth.resourceId, resources.resourceId)
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
resourceHeaderAuthExtendedCompatibility,
|
|
||||||
eq(
|
eq(
|
||||||
resourceHeaderAuthExtendedCompatibility.resourceId,
|
resourcePolicyPassword.resourcePolicyId,
|
||||||
resources.resourceId
|
resourcePolicies.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
resourcePolicyPincode,
|
||||||
|
eq(
|
||||||
|
resourcePolicyPincode.resourcePolicyId,
|
||||||
|
resourcePolicies.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
eq(
|
||||||
|
resourcePolicyHeaderAuth.resourcePolicyId,
|
||||||
|
resourcePolicies.resourcePolicyId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
|
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
|
||||||
@@ -206,10 +222,10 @@ function queryResourcesBase() {
|
|||||||
)
|
)
|
||||||
.groupBy(
|
.groupBy(
|
||||||
resources.resourceId,
|
resources.resourceId,
|
||||||
resourcePassword.passwordId,
|
resourcePolicies.resourcePolicyId,
|
||||||
resourcePincode.pincodeId,
|
resourcePolicyPassword.passwordId,
|
||||||
resourceHeaderAuth.headerAuthId,
|
resourcePolicyPincode.pincodeId,
|
||||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
|
resourcePolicyHeaderAuth.headerAuthId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,21 +371,21 @@ export async function listResources(
|
|||||||
case "protected":
|
case "protected":
|
||||||
conditions.push(
|
conditions.push(
|
||||||
or(
|
or(
|
||||||
eq(resources.sso, true),
|
eq(resourcePolicies.sso, true),
|
||||||
eq(resources.emailWhitelistEnabled, true),
|
eq(resourcePolicies.emailWhitelistEnabled, true),
|
||||||
not(isNull(resourceHeaderAuth.headerAuthId)),
|
not(isNull(resourcePolicyHeaderAuth.headerAuthId)),
|
||||||
not(isNull(resourcePincode.pincodeId)),
|
not(isNull(resourcePolicyPincode.pincodeId)),
|
||||||
not(isNull(resourcePassword.passwordId))
|
not(isNull(resourcePolicyPassword.passwordId))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "not_protected":
|
case "not_protected":
|
||||||
conditions.push(
|
conditions.push(
|
||||||
not(eq(resources.sso, true)),
|
not(eq(resourcePolicies.sso, true)),
|
||||||
not(eq(resources.emailWhitelistEnabled, true)),
|
not(eq(resourcePolicies.emailWhitelistEnabled, true)),
|
||||||
isNull(resourceHeaderAuth.headerAuthId),
|
isNull(resourcePolicyHeaderAuth.headerAuthId),
|
||||||
isNull(resourcePincode.pincodeId),
|
isNull(resourcePolicyPincode.pincodeId),
|
||||||
isNull(resourcePassword.passwordId)
|
isNull(resourcePolicyPassword.passwordId)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -446,9 +462,9 @@ export async function listResources(
|
|||||||
ssl: row.ssl,
|
ssl: row.ssl,
|
||||||
fullDomain: row.fullDomain,
|
fullDomain: row.fullDomain,
|
||||||
passwordId: row.passwordId,
|
passwordId: row.passwordId,
|
||||||
sso: row.sso,
|
sso: row.sso ?? false,
|
||||||
pincodeId: row.pincodeId,
|
pincodeId: row.pincodeId,
|
||||||
whitelist: row.whitelist,
|
whitelist: row.whitelist ?? false,
|
||||||
http: row.http,
|
http: row.http,
|
||||||
protocol: row.protocol,
|
protocol: row.protocol,
|
||||||
proxyPort: row.proxyPort,
|
proxyPort: row.proxyPort,
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { Resource, ResourcePolicy } from "@server/db";
|
||||||
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
|
|
||||||
export type GetMaintenanceInfoResponse = {
|
export type GetMaintenanceInfoResponse = {
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -8,3 +11,19 @@ export type GetMaintenanceInfoResponse = {
|
|||||||
maintenanceMessage: string | null;
|
maintenanceMessage: string | null;
|
||||||
maintenanceEstimatedTime: string | null;
|
maintenanceEstimatedTime: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AttachedResource = Pick<
|
||||||
|
Resource,
|
||||||
|
"resourceId" | "name" | "fullDomain"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ResourcePolicyWithResources = Pick<
|
||||||
|
ResourcePolicy,
|
||||||
|
"resourcePolicyId" | "niceId" | "name" | "orgId"
|
||||||
|
> & {
|
||||||
|
resources: Array<AttachedResource>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListResourcePoliciesResponse = PaginatedResponse<{
|
||||||
|
policies: Array<ResourcePolicyWithResources>;
|
||||||
|
}>;
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, domainNamespaces, loginPage } from "@server/db";
|
import {
|
||||||
|
db,
|
||||||
|
domainNamespaces,
|
||||||
|
loginPage,
|
||||||
|
resourceHeaderAuth,
|
||||||
|
resourceHeaderAuthExtendedCompatibility,
|
||||||
|
resourcePassword,
|
||||||
|
resourcePincode,
|
||||||
|
resourceRules,
|
||||||
|
resourceWhitelist
|
||||||
|
} from "@server/db";
|
||||||
import {
|
import {
|
||||||
domains,
|
domains,
|
||||||
Org,
|
Org,
|
||||||
orgDomains,
|
orgDomains,
|
||||||
orgs,
|
orgs,
|
||||||
Resource,
|
Resource,
|
||||||
|
resourcePolicies,
|
||||||
resources
|
resources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { eq, and, ne } from "drizzle-orm";
|
import { eq, and, ne } from "drizzle-orm";
|
||||||
@@ -24,7 +35,10 @@ import {
|
|||||||
import { registry } from "@server/openApi";
|
import { registry } from "@server/openApi";
|
||||||
import { OpenAPITags } from "@server/openApi";
|
import { OpenAPITags } from "@server/openApi";
|
||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||||
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
|
import {
|
||||||
|
validateAndConstructDomain,
|
||||||
|
checkWildcardDomainConflict
|
||||||
|
} from "@server/lib/domainUtils";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
@@ -68,7 +82,8 @@ const updateHttpResourceBodySchema = z
|
|||||||
maintenanceTitle: z.string().max(255).nullable().optional(),
|
maintenanceTitle: z.string().max(255).nullable().optional(),
|
||||||
maintenanceMessage: z.string().max(2000).nullable().optional(),
|
maintenanceMessage: z.string().max(2000).nullable().optional(),
|
||||||
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
|
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
|
||||||
postAuthPath: z.string().nullable().optional()
|
postAuthPath: z.string().nullable().optional(),
|
||||||
|
resourcePolicyId: z.number().nullable().optional()
|
||||||
})
|
})
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
error: "At least one field must be provided for update"
|
error: "At least one field must be provided for update"
|
||||||
@@ -165,7 +180,8 @@ const updateRawResourceBodySchema = z
|
|||||||
stickySession: z.boolean().optional(),
|
stickySession: z.boolean().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
proxyProtocol: z.boolean().optional(),
|
proxyProtocol: z.boolean().optional(),
|
||||||
proxyProtocolVersion: z.int().min(1).optional()
|
proxyProtocolVersion: z.int().min(1).optional(),
|
||||||
|
resourcePolicyId: z.number().nullable().optional()
|
||||||
})
|
})
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
error: "At least one field must be provided for update"
|
error: "At least one field must be provided for update"
|
||||||
@@ -301,6 +317,42 @@ async function updateHttpResource(
|
|||||||
|
|
||||||
const updateData = parsedBody.data;
|
const updateData = parsedBody.data;
|
||||||
|
|
||||||
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
|
resource.orgId,
|
||||||
|
tierMatrix.wildcardSubdomain
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updateData.resourcePolicyId != null) {
|
||||||
|
if (!isLicensed) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Resource policies are not supported on your current plan. Please upgrade to access this feature."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingPolicy] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicies)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePolicies.resourcePolicyId,
|
||||||
|
updateData.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingPolicy) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource policy with ID ${updateData.resourcePolicyId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (updateData.niceId) {
|
if (updateData.niceId) {
|
||||||
const [existingResource] = await db
|
const [existingResource] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -326,10 +378,6 @@ async function updateHttpResource(
|
|||||||
|
|
||||||
// Wildcard subdomains are a paid feature
|
// Wildcard subdomains are a paid feature
|
||||||
if (updateData.subdomain && updateData.subdomain.includes("*")) {
|
if (updateData.subdomain && updateData.subdomain.includes("*")) {
|
||||||
const isLicensed = await isLicensedOrSubscribed(
|
|
||||||
resource.orgId,
|
|
||||||
tierMatrix.wildcardSubdomain
|
|
||||||
);
|
|
||||||
if (!isLicensed) {
|
if (!isLicensed) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -474,10 +522,6 @@ async function updateHttpResource(
|
|||||||
headers = null;
|
headers = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLicensed = await isLicensedOrSubscribed(
|
|
||||||
resource.orgId,
|
|
||||||
tierMatrix.maintencePage
|
|
||||||
);
|
|
||||||
if (!isLicensed) {
|
if (!isLicensed) {
|
||||||
updateData.maintenanceModeEnabled = undefined;
|
updateData.maintenanceModeEnabled = undefined;
|
||||||
updateData.maintenanceModeType = undefined;
|
updateData.maintenanceModeType = undefined;
|
||||||
@@ -535,38 +579,122 @@ async function updateRawResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateData = parsedBody.data;
|
const updateData = parsedBody.data;
|
||||||
|
let updatedResource: Resource | null = null;
|
||||||
|
|
||||||
if (updateData.niceId) {
|
const [existingResource] = await db
|
||||||
const [existingResource] = await db
|
.select()
|
||||||
.select()
|
.from(resources)
|
||||||
.from(resources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resources.niceId, updateData.niceId),
|
|
||||||
eq(resources.orgId, resource.orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
existingResource &&
|
|
||||||
existingResource.resourceId !== resource.resourceId
|
|
||||||
) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.CONFLICT,
|
|
||||||
`A resource with niceId "${updateData.niceId}" already exists`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedResource = await db
|
|
||||||
.update(resources)
|
|
||||||
.set(updateData)
|
|
||||||
.where(eq(resources.resourceId, resource.resourceId))
|
.where(eq(resources.resourceId, resource.resourceId))
|
||||||
.returning();
|
.limit(1);
|
||||||
|
|
||||||
if (updatedResource.length === 0) {
|
await db.transaction(async (trx) => {
|
||||||
|
if (updateData.resourcePolicyId != null) {
|
||||||
|
const [existingPolicy] = await trx
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicies)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePolicies.resourcePolicyId,
|
||||||
|
updateData.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingPolicy) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource policy with ID ${updateData.resourcePolicyId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// we are in an inline policy and we need to clear out the old tables
|
||||||
|
await Promise.all([
|
||||||
|
trx
|
||||||
|
.delete(resourcePassword)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePassword.resourceId,
|
||||||
|
existingResource.resourceId
|
||||||
|
)
|
||||||
|
),
|
||||||
|
trx
|
||||||
|
.delete(resourcePincode)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePincode.resourceId,
|
||||||
|
existingResource.resourceId
|
||||||
|
)
|
||||||
|
),
|
||||||
|
trx
|
||||||
|
.delete(resourceHeaderAuth)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourceHeaderAuth.resourceId,
|
||||||
|
existingResource.resourceId
|
||||||
|
)
|
||||||
|
),
|
||||||
|
trx
|
||||||
|
.delete(resourceHeaderAuthExtendedCompatibility)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourceHeaderAuthExtendedCompatibility.resourceId,
|
||||||
|
existingResource.resourceId
|
||||||
|
)
|
||||||
|
),
|
||||||
|
trx
|
||||||
|
.delete(resourceWhitelist)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourceWhitelist.resourceId,
|
||||||
|
existingResource.resourceId
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
trx
|
||||||
|
.delete(resourceRules)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourceRules.resourceId,
|
||||||
|
existingResource.resourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateData.niceId) {
|
||||||
|
const [existingResourceConflict] = await trx
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.niceId, updateData.niceId),
|
||||||
|
eq(resources.orgId, resource.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
existingResourceConflict &&
|
||||||
|
existingResourceConflict.resourceId !== resource.resourceId
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
`A resource with niceId "${updateData.niceId}" already exists`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[updatedResource] = await trx
|
||||||
|
.update(resources)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(resources.resourceId, resource.resourceId))
|
||||||
|
.returning();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedResource) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.NOT_FOUND,
|
HttpCode.NOT_FOUND,
|
||||||
@@ -576,7 +704,7 @@ async function updateRawResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: updatedResource[0],
|
data: updatedResource,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Non-http Resource updated successfully",
|
message: "Non-http Resource updated successfully",
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ const listSitesSchema = z.object({
|
|||||||
page: z.coerce
|
page: z.coerce
|
||||||
.number<string>() // for prettier formatting
|
.number<string>() // for prettier formatting
|
||||||
.int()
|
.int()
|
||||||
.min(0)
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.catch(1)
|
.catch(1)
|
||||||
.default(1)
|
.default(1)
|
||||||
|
|||||||
@@ -47,10 +47,7 @@ export async function queryUser(orgId: string, userId: string) {
|
|||||||
.from(userOrgRoles)
|
.from(userOrgRoles)
|
||||||
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId))
|
||||||
eq(userOrgRoles.userId, userId),
|
|
||||||
eq(userOrgRoles.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const isAdmin = roleRows.some((r) => r.isAdmin);
|
const isAdmin = roleRows.some((r) => r.isAdmin);
|
||||||
@@ -146,7 +143,7 @@ export async function getOrgUser(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"User does not have permission perform this action"
|
"User does not have permission to get organization user details"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/app/[orgId]/settings/(private)/policies/layout.tsx
Normal file
23
src/app/[orgId]/settings/(private)/policies/layout.tsx
Normal file
@@ -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 <OrgProvider org={org}>{props.children}</OrgProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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 { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider";
|
||||||
|
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 policyResponse: GetResourcePolicyResponse | null = null;
|
||||||
|
try {
|
||||||
|
const res = await internal.get<
|
||||||
|
AxiosResponse<GetResourcePolicyResponse>
|
||||||
|
>(
|
||||||
|
`/org/${params.orgId}/resource-policy/${params.niceId}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
policyResponse = res.data.data;
|
||||||
|
} catch {
|
||||||
|
redirect(`/${params.orgId}/settings/policies/resource`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policyResponse) {
|
||||||
|
redirect(`/${params.orgId}/settings/policies/resource`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={t("resourcePolicySetting", {
|
||||||
|
policyName: policyResponse.name
|
||||||
|
})}
|
||||||
|
description={t("resourcePolicySettingDescription")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/${params.orgId}/settings/policies/resource`}>
|
||||||
|
{t("resourcePoliciesSeeAll")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResourcePolicyProvider policy={policyResponse}>
|
||||||
|
<EditPolicyForm />
|
||||||
|
</ResourcePolicyProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { CreatePolicyForm } from "@app/components/resource-policy/CreatePolicyForm";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export interface CreateResourcePolicyPageProps {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CreateResourcePolicyPage(
|
||||||
|
props: CreateResourcePolicyPageProps
|
||||||
|
) {
|
||||||
|
const params = await props.params;
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={t("resourcePoliciesCreate")}
|
||||||
|
description={t("resourcePoliciesCreateDescription")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/${params.orgId}/settings/policies/resource`}>
|
||||||
|
{t("resourcePoliciesSeeAll")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreatePolicyForm />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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 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<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ResourcePoliciesPage(
|
||||||
|
props: ResourcePoliciesPageProps
|
||||||
|
) {
|
||||||
|
const params = await props.params;
|
||||||
|
const t = await getTranslations();
|
||||||
|
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<ListResourcePoliciesResponse>
|
||||||
|
>(
|
||||||
|
`/org/${params.orgId}/resource-policies?${searchParams.toString()}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
const responseData = res.data.data;
|
||||||
|
policies = responseData.policies;
|
||||||
|
pagination = responseData.pagination;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={t("resourcePoliciesTitle")}
|
||||||
|
description={t("resourcePoliciesDescription")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ResourcePoliciesTable
|
||||||
|
policies={policies}
|
||||||
|
orgId={params.orgId}
|
||||||
|
rowCount={pagination.total}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.page - 1,
|
||||||
|
pageSize: pagination.pageSize
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { Layout } from "@app/components/Layout";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
import { orgNavSections } from "@app/app/navigation";
|
import { orgNavSections } from "@app/app/navigation";
|
||||||
|
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -48,13 +49,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const getOrgUser = cache(() =>
|
const orgUser = await getCachedOrgUser(params.orgId, user.userId);
|
||||||
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
|
||||||
`/org/${params.orgId}/user/${user.userId}`,
|
|
||||||
cookie
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const orgUser = await getOrgUser();
|
|
||||||
|
|
||||||
if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) {
|
if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) {
|
||||||
throw new Error(t("userErrorNotAdminOrOwner"));
|
throw new Error(t("userErrorNotAdminOrOwner"));
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -96,10 +96,10 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
|||||||
title: t("authentication"),
|
title: t("authentication"),
|
||||||
href: `/{orgId}/settings/resources/proxy/{niceId}/authentication`
|
href: `/{orgId}/settings/resources/proxy/{niceId}/authentication`
|
||||||
});
|
});
|
||||||
navItems.push({
|
// navItems.push({
|
||||||
title: t("rules"),
|
// title: t("rules"),
|
||||||
href: `/{orgId}/settings/resources/proxy/{niceId}/rules`
|
// href: `/{orgId}/settings/resources/proxy/{niceId}/rules`
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -92,7 +92,13 @@ import { useTranslations } from "next-intl";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { toASCII } from "punycode";
|
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 { Controller, useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -218,7 +224,7 @@ export default function Page() {
|
|||||||
>([]);
|
>([]);
|
||||||
const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas");
|
const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas");
|
||||||
|
|
||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, startTransition] = useTransition();
|
||||||
const [showSnippets, setShowSnippets] = useState(false);
|
const [showSnippets, setShowSnippets] = useState(false);
|
||||||
const [niceId, setNiceId] = useState<string>("");
|
const [niceId, setNiceId] = useState<string>("");
|
||||||
|
|
||||||
@@ -328,7 +334,7 @@ export default function Page() {
|
|||||||
id: "raw" as ResourceType,
|
id: "raw" as ResourceType,
|
||||||
title: t("resourceRaw"),
|
title: t("resourceRaw"),
|
||||||
description:
|
description:
|
||||||
build == "saas"
|
build === "saas"
|
||||||
? t("resourceRawDescriptionCloud")
|
? t("resourceRawDescriptionCloud")
|
||||||
: t("resourceRawDescription")
|
: t("resourceRawDescription")
|
||||||
}
|
}
|
||||||
@@ -473,8 +479,6 @@ export default function Page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
setCreateLoading(true);
|
|
||||||
|
|
||||||
const baseData = baseForm.getValues();
|
const baseData = baseForm.getValues();
|
||||||
const isHttp = baseData.http;
|
const isHttp = baseData.http;
|
||||||
|
|
||||||
@@ -610,8 +614,6 @@ export default function Page() {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setCreateLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1465,7 +1467,7 @@ export default function Page() {
|
|||||||
console.log(httpForm.getValues());
|
console.log(httpForm.getValues());
|
||||||
|
|
||||||
if (baseValid && settingsValid) {
|
if (baseValid && settingsValid) {
|
||||||
onSubmit();
|
startTransition(onSubmit);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
loading={createLoading}
|
loading={createLoading}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
Fingerprint,
|
Fingerprint,
|
||||||
Globe,
|
Globe,
|
||||||
|
GlobeIcon,
|
||||||
GlobeLock,
|
GlobeLock,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Laptop,
|
Laptop,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
ScanEye,
|
ScanEye,
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
|
ShieldIcon,
|
||||||
SquareMousePointer,
|
SquareMousePointer,
|
||||||
TicketCheck,
|
TicketCheck,
|
||||||
Unplug,
|
Unplug,
|
||||||
@@ -99,7 +101,7 @@ export const orgNavSections = (
|
|||||||
href: "/{orgId}/settings/domains",
|
href: "/{orgId}/settings/domains",
|
||||||
icon: <Globe className="size-4 flex-none" />
|
icon: <Globe className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
...(build == "saas"
|
...(build === "saas"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: "sidebarRemoteExitNodes",
|
title: "sidebarRemoteExitNodes",
|
||||||
@@ -134,6 +136,24 @@ export const orgNavSections = (
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
...(build !== "oss"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: "sidebarPolicies",
|
||||||
|
|
||||||
|
icon: <ShieldIcon className="size-4 flex-none" />,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "sidebarResourcePolicies",
|
||||||
|
href: "/{orgId}/settings/policies/resource",
|
||||||
|
icon: (
|
||||||
|
<GlobeIcon className="size-4 flex-none" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
// PaidFeaturesAlert
|
// PaidFeaturesAlert
|
||||||
...((build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
|
...((build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
|
||||||
build === "saas" ||
|
build === "saas" ||
|
||||||
|
|||||||
@@ -28,15 +28,14 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
|||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types";
|
import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types";
|
||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
|
|
||||||
export type AuthPageCustomizationProps = {
|
export type AuthPageCustomizationProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
|
|||||||
@@ -193,22 +193,17 @@ export default function ProxyResourcesTable({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteResource = (resourceId: number) => {
|
const deleteResource = async (resourceId: number) => {
|
||||||
api.delete(`/resource/${resourceId}`)
|
await api.delete(`/resource/${resourceId}`).catch((e) => {
|
||||||
.catch((e) => {
|
console.error(t("resourceErrorDelte"), e);
|
||||||
console.error(t("resourceErrorDelte"), e);
|
toast({
|
||||||
toast({
|
variant: "destructive",
|
||||||
variant: "destructive",
|
title: t("resourceErrorDelte"),
|
||||||
title: t("resourceErrorDelte"),
|
description: formatAxiosError(e, t("resourceErrorDelte"))
|
||||||
description: formatAxiosError(e, t("resourceErrorDelte"))
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
startTransition(() => {
|
|
||||||
router.refresh();
|
|
||||||
setIsDeleteModalOpen(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function toggleResourceEnabled(val: boolean, resourceId: number) {
|
async function toggleResourceEnabled(val: boolean, resourceId: number) {
|
||||||
@@ -770,7 +765,11 @@ export default function ProxyResourcesTable({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
buttonText={t("resourceDeleteConfirm")}
|
buttonText={t("resourceDeleteConfirm")}
|
||||||
onConfirm={async () => deleteResource(selectedResource!.id)}
|
onConfirm={async () =>
|
||||||
|
startTransition(() =>
|
||||||
|
deleteResource(selectedResource!.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
string={selectedResource.name}
|
string={selectedResource.name}
|
||||||
title={t("resourceDelete")}
|
title={t("resourceDelete")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
311
src/components/ResourcePoliciesTable.tsx
Normal file
311
src/components/ResourcePoliciesTable.tsx
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import type {
|
||||||
|
AttachedResource,
|
||||||
|
ListResourcePoliciesResponse
|
||||||
|
} from "@server/routers/resource/types";
|
||||||
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
ChevronDown,
|
||||||
|
MoreHorizontal,
|
||||||
|
Waypoints
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
|
import type { ExtendedColumnDef } from "./ui/data-table";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "./ui/dropdown-menu";
|
||||||
|
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
|
||||||
|
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||||
|
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||||
|
|
||||||
|
type ResourcePolicyRow = ListResourcePoliciesResponse["policies"][number];
|
||||||
|
|
||||||
|
export type ResourcePoliciesTableProps = {
|
||||||
|
policies: Array<ResourcePolicyRow>;
|
||||||
|
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 [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [selectedResourcePolicy, setSelectedResourcePolicy] =
|
||||||
|
useState<ResourcePolicyRow | null>(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();
|
||||||
|
|
||||||
|
const refreshData = () => {
|
||||||
|
startTransition(() => {
|
||||||
|
try {
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("refreshError"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function ResourceListCell({
|
||||||
|
resources
|
||||||
|
}: {
|
||||||
|
resources?: AttachedResource[];
|
||||||
|
}) {
|
||||||
|
if (!resources || resources.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="LOOK_FOR_ME"
|
||||||
|
className="flex items-center gap-2 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Waypoints className="size-4 flex-none" />
|
||||||
|
<span className="text-sm">
|
||||||
|
{t("resourcePoliciesAttachedResourcesEmpty")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 h-8 px-0 font-normal"
|
||||||
|
>
|
||||||
|
<Waypoints className="size-4 flex-none" />
|
||||||
|
<span className="text-sm">
|
||||||
|
{t("resourcePoliciesAttachedResources", {
|
||||||
|
count: resources.length
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="min-w-70">
|
||||||
|
{resources.map((resource) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={resource.resourceId}
|
||||||
|
className="flex items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{resource.name}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`capitalize text-muted-foreground`}
|
||||||
|
>
|
||||||
|
{resource.fullDomain}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyColumns: ExtendedColumnDef<ResourcePolicyRow>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
enableHiding: false,
|
||||||
|
friendlyName: t("name"),
|
||||||
|
header: () => <span className="p-3">{t("name")}</span>,
|
||||||
|
cell: ({ row }) => <span>{row.original.name}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "niceId",
|
||||||
|
accessorKey: "nice",
|
||||||
|
friendlyName: t("identifier"),
|
||||||
|
enableHiding: true,
|
||||||
|
header: () => <span className="p-3">{t("identifier")}</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return <span>{row.original.niceId || "-"}</span>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "resources",
|
||||||
|
accessorKey: "resources",
|
||||||
|
friendlyName: t("resourcePoliciesAttachedResourcesColumnTitle"),
|
||||||
|
header: () => (
|
||||||
|
<span className="p-3">
|
||||||
|
{t("resourcePoliciesAttachedResourcesColumnTitle")}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return <ResourceListCell resources={row.original.resources} />;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
enableHiding: false,
|
||||||
|
header: () => <span className="p-3"></span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const policyRow = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">
|
||||||
|
{t("openMenu")}
|
||||||
|
</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<Link
|
||||||
|
className="block w-full"
|
||||||
|
href={`/${policyRow.orgId}/settings/policies/resource/${policyRow.niceId}`}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
{t("viewSettings")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</Link>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedResourcePolicy(policyRow);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-red-500">
|
||||||
|
{t("delete")}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Link
|
||||||
|
href={`/${policyRow.orgId}/settings/policies/resource/${policyRow.niceId}`}
|
||||||
|
>
|
||||||
|
<Button variant={"outline"}>
|
||||||
|
{t("edit")}
|
||||||
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<PaidFeaturesAlert
|
||||||
|
tiers={tierMatrix[TierFeature.ResourcePolicies]}
|
||||||
|
/>
|
||||||
|
{selectedResourcePolicy && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
setOpen={(val) => {
|
||||||
|
setIsDeleteModalOpen(val);
|
||||||
|
setSelectedResourcePolicy(null);
|
||||||
|
}}
|
||||||
|
dialog={
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>{t("resourcePolicyQuestionRemove")}</p>
|
||||||
|
<p>{t("resourcePolicyMessageRemove")}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
buttonText={t("resourcePolicyDeleteConfirm")}
|
||||||
|
onConfirm={async () =>
|
||||||
|
deleteResourcePolicy(
|
||||||
|
selectedResourcePolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
string={selectedResourcePolicy.name}
|
||||||
|
title={t("resourcePolicyDelete")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ControlledDataTable
|
||||||
|
columns={proxyColumns}
|
||||||
|
rows={policies}
|
||||||
|
tableId="resource-policies"
|
||||||
|
searchPlaceholder={t("resourcePoliciesSearch")}
|
||||||
|
pagination={pagination}
|
||||||
|
rowCount={rowCount}
|
||||||
|
onSearch={handleSearchChange}
|
||||||
|
onPaginationChange={handlePaginationChange}
|
||||||
|
onAdd={() =>
|
||||||
|
startNavigation(() =>
|
||||||
|
router.push(
|
||||||
|
`/${orgId}/settings/policies/resource/create`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
addButtonText={t("resourcePoliciesAdd")}
|
||||||
|
onRefresh={refreshData}
|
||||||
|
isRefreshing={isRefreshing || isFiltering}
|
||||||
|
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||||
|
enableColumnVisibility
|
||||||
|
columnVisibility={{ niceId: false }}
|
||||||
|
stickyLeftColumn="name"
|
||||||
|
stickyRightColumn="actions"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,12 +61,19 @@ export function SettingsSectionBody({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSectionFooter({
|
export function SettingsSectionFooter({
|
||||||
children
|
children,
|
||||||
|
className
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col md:flex-row justify-end space-y-2 md:space-y-0 md:space-x-2 mt-auto pt-6">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col md:flex-row justify-end space-y-2 md:space-y-0 md:space-x-2 mt-auto pt-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,11 +25,15 @@ export function StrategySelect<TValue extends string>({
|
|||||||
value: controlledValue,
|
value: controlledValue,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
onChange,
|
onChange,
|
||||||
cols
|
cols = 1
|
||||||
}: StrategySelectProps<TValue>) {
|
}: StrategySelectProps<TValue>) {
|
||||||
const [uncontrolledSelected, setUncontrolledSelected] = useState<TValue | undefined>(defaultValue);
|
const [uncontrolledSelected, setUncontrolledSelected] = useState<
|
||||||
|
TValue | undefined
|
||||||
|
>(defaultValue);
|
||||||
const isControlled = controlledValue !== undefined;
|
const isControlled = controlledValue !== undefined;
|
||||||
const selected = isControlled ? (controlledValue ?? undefined) : uncontrolledSelected;
|
const selected = isControlled
|
||||||
|
? (controlledValue ?? undefined)
|
||||||
|
: uncontrolledSelected;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
@@ -39,7 +43,11 @@ export function StrategySelect<TValue extends string>({
|
|||||||
if (!isControlled) setUncontrolledSelected(typedValue);
|
if (!isControlled) setUncontrolledSelected(typedValue);
|
||||||
onChange?.(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<TValue>) => (
|
{options.map((option: StrategyOption<TValue>) => (
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type MultiSelectTagsProps<T extends TagValue> = {
|
|||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void;
|
||||||
ref?: Ref<HTMLButtonElement>;
|
ref?: Ref<HTMLButtonElement>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
lockedIds?: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MultiSelectContent<T extends TagValue>({
|
export function MultiSelectContent<T extends TagValue>({
|
||||||
@@ -32,7 +33,8 @@ export function MultiSelectContent<T extends TagValue>({
|
|||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
onSearch,
|
onSearch,
|
||||||
onChange
|
onChange,
|
||||||
|
lockedIds
|
||||||
}: MultiSelectTagsProps<T>) {
|
}: MultiSelectTagsProps<T>) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const selectedValues = new Set(value.map((v) => v.id));
|
const selectedValues = new Set(value.map((v) => v.id));
|
||||||
@@ -48,33 +50,38 @@ export function MultiSelectContent<T extends TagValue>({
|
|||||||
{emptyPlaceholder ?? t("noResults")}
|
{emptyPlaceholder ?? t("noResults")}
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{options.map((option) => (
|
{options.map((option) => {
|
||||||
<CommandItem
|
const isLocked = lockedIds?.has(option.id);
|
||||||
value={option.id}
|
return (
|
||||||
key={option.id}
|
<CommandItem
|
||||||
onSelect={() => {
|
value={option.id}
|
||||||
let newValues = [];
|
key={option.id}
|
||||||
if (selectedValues.has(option.id)) {
|
disabled={isLocked}
|
||||||
newValues = value.filter(
|
onSelect={() => {
|
||||||
(v) => v.id !== option.id
|
if (isLocked) return;
|
||||||
);
|
let newValues = [];
|
||||||
} else {
|
if (selectedValues.has(option.id)) {
|
||||||
newValues = [...value, option];
|
newValues = value.filter(
|
||||||
}
|
(v) => v.id !== option.id
|
||||||
onChange(newValues);
|
);
|
||||||
}}
|
} else {
|
||||||
>
|
newValues = [...value, option];
|
||||||
<CheckIcon
|
}
|
||||||
className={cn(
|
onChange(newValues);
|
||||||
"mr-2 h-4 w-4",
|
}}
|
||||||
selectedValues.has(option.id)
|
>
|
||||||
? "opacity-100"
|
<CheckIcon
|
||||||
: "opacity-0"
|
className={cn(
|
||||||
)}
|
"mr-2 h-4 w-4",
|
||||||
/>
|
selectedValues.has(option.id)
|
||||||
{`${option.text}`}
|
? "opacity-100"
|
||||||
</CommandItem>
|
: "opacity-0"
|
||||||
))}
|
)}
|
||||||
|
/>
|
||||||
|
{`${option.text}`}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
PopoverTrigger
|
PopoverTrigger
|
||||||
} from "@app/components/ui/popover";
|
} from "@app/components/ui/popover";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { ChevronDownIcon, XIcon } from "lucide-react";
|
import { ChevronDownIcon, LockIcon, XIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
type MultiSelectTagsProps,
|
type MultiSelectTagsProps,
|
||||||
type TagValue,
|
type TagValue,
|
||||||
@@ -16,10 +16,12 @@ export interface MultiSelectInputProps<
|
|||||||
T extends TagValue
|
T extends TagValue
|
||||||
> extends MultiSelectTagsProps<T> {
|
> extends MultiSelectTagsProps<T> {
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
|
lockedIds?: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MultiSelectTagInput<T extends TagValue>({
|
export function MultiSelectTagInput<T extends TagValue>({
|
||||||
buttonText,
|
buttonText,
|
||||||
|
lockedIds,
|
||||||
...props
|
...props
|
||||||
}: MultiSelectInputProps<T>) {
|
}: MultiSelectInputProps<T>) {
|
||||||
const selectedValues = new Set(props.value.map((v) => v.id));
|
const selectedValues = new Set(props.value.map((v) => v.id));
|
||||||
@@ -52,46 +54,63 @@ export function MultiSelectTagInput<T extends TagValue>({
|
|||||||
"overflow-x-auto"
|
"overflow-x-auto"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{props.value.map((option) => (
|
{props.value.map((option) => {
|
||||||
<span
|
const isLocked = lockedIds?.has(option.id);
|
||||||
key={option.id}
|
return (
|
||||||
className={cn(
|
<span
|
||||||
"bg-muted-foreground/10 font-normal text-foreground rounded-sm",
|
key={option.id}
|
||||||
"py-1 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5"
|
className={cn(
|
||||||
)}
|
"bg-muted-foreground/10 font-normal text-foreground rounded-sm",
|
||||||
onClick={(e) => e.stopPropagation()}
|
"py-1 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5",
|
||||||
>
|
isLocked && "opacity-60"
|
||||||
{option.text}
|
)}
|
||||||
<button
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="p-0.5 flex-none cursor-pointer"
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
let newValues = [];
|
|
||||||
if (selectedValues.has(option.id)) {
|
|
||||||
newValues = props.value.filter(
|
|
||||||
(v) => v.id !== option.id
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
newValues = [
|
|
||||||
...props.value,
|
|
||||||
option
|
|
||||||
];
|
|
||||||
}
|
|
||||||
props.onChange(newValues);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<XIcon className="size-3.5" />
|
{option.text}
|
||||||
</button>
|
{isLocked ? (
|
||||||
</span>
|
<span className="p-0.5 flex-none">
|
||||||
))}
|
<LockIcon className="size-3" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="p-0.5 flex-none cursor-pointer"
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
let newValues = [];
|
||||||
|
if (
|
||||||
|
selectedValues.has(
|
||||||
|
option.id
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
newValues =
|
||||||
|
props.value.filter(
|
||||||
|
(v) =>
|
||||||
|
v.id !==
|
||||||
|
option.id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
newValues = [
|
||||||
|
...props.value,
|
||||||
|
option
|
||||||
|
];
|
||||||
|
}
|
||||||
|
props.onChange(newValues);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<span className="pl-1 font-normal">{buttonText}</span>
|
<span className="pl-1 font-normal">{buttonText}</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0">
|
<PopoverContent className="p-0">
|
||||||
<MultiSelectContent {...props} />
|
<MultiSelectContent {...props} lockedIds={lockedIds} />
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,521 @@
|
|||||||
|
"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 { createPolicySchema, 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 { cn } from "@app/lib/cn";
|
||||||
|
import { Binary, Bot, Key, Plus } from "lucide-react";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { type UseFormReturn, useForm, useWatch } 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<PolicyFormValues, any, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CreatePolicyAuthMethodsSectionForm({
|
||||||
|
form: parentForm
|
||||||
|
}: CreatePolicyAuthMethodsSectionFormProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false);
|
||||||
|
const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false);
|
||||||
|
const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(
|
||||||
|
createPolicySchema.pick({
|
||||||
|
password: true,
|
||||||
|
pincode: true,
|
||||||
|
headerAuth: true
|
||||||
|
})
|
||||||
|
),
|
||||||
|
defaultValues: {
|
||||||
|
password: null,
|
||||||
|
pincode: null,
|
||||||
|
headerAuth: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = form.watch((values) => {
|
||||||
|
parentForm.setValue("password", values.password as any);
|
||||||
|
parentForm.setValue("pincode", values.pincode as any);
|
||||||
|
parentForm.setValue("headerAuth", values.headerAuth as any);
|
||||||
|
});
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, [form, parentForm]);
|
||||||
|
|
||||||
|
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),
|
||||||
|
defaultValues: { password: "" }
|
||||||
|
});
|
||||||
|
|
||||||
|
const pincodeForm = useForm({
|
||||||
|
resolver: zodResolver(setPincodeSchema),
|
||||||
|
defaultValues: { pincode: "" }
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerAuthForm = useForm({
|
||||||
|
resolver: zodResolver(setHeaderAuthSchema),
|
||||||
|
defaultValues: { user: "", password: "", extendedCompatibility: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isExpanded) {
|
||||||
|
return (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("resourceAuthMethods")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("resourcePolicyAuthMethodsDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{t("resourcePolicyAuthMethodAdd")}
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Password Credenza */}
|
||||||
|
<Credenza
|
||||||
|
open={isSetPasswordOpen}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setIsSetPasswordOpen(val);
|
||||||
|
if (!val) passwordForm.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{t("resourcePasswordSetupTitle")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("resourcePasswordSetupTitleDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<Form {...passwordForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={passwordForm.handleSubmit((data) => {
|
||||||
|
form.setValue("password", data);
|
||||||
|
setIsSetPasswordOpen(false);
|
||||||
|
passwordForm.reset();
|
||||||
|
})}
|
||||||
|
className="space-y-4"
|
||||||
|
id="set-password-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={passwordForm.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("password")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">{t("close")}</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button type="submit" form="set-password-form">
|
||||||
|
{t("resourcePasswordSubmit")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
|
||||||
|
{/* Pincode Credenza */}
|
||||||
|
<Credenza
|
||||||
|
open={isSetPincodeOpen}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setIsSetPincodeOpen(val);
|
||||||
|
if (!val) pincodeForm.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{t("resourcePincodeSetupTitle")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("resourcePincodeSetupTitleDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<Form {...pincodeForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={pincodeForm.handleSubmit((data) => {
|
||||||
|
form.setValue("pincode", data);
|
||||||
|
setIsSetPincodeOpen(false);
|
||||||
|
pincodeForm.reset();
|
||||||
|
})}
|
||||||
|
className="space-y-4"
|
||||||
|
id="set-pincode-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={pincodeForm.control}
|
||||||
|
name="pincode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("resourcePincode")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<InputOTP
|
||||||
|
autoComplete="false"
|
||||||
|
maxLength={6}
|
||||||
|
{...field}
|
||||||
|
>
|
||||||
|
<InputOTPGroup className="flex">
|
||||||
|
<InputOTPSlot
|
||||||
|
index={0}
|
||||||
|
obscured
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={1}
|
||||||
|
obscured
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={2}
|
||||||
|
obscured
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={3}
|
||||||
|
obscured
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={4}
|
||||||
|
obscured
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={5}
|
||||||
|
obscured
|
||||||
|
/>
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">{t("close")}</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button type="submit" form="set-pincode-form">
|
||||||
|
{t("resourcePincodeSubmit")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
|
||||||
|
{/* Header Auth Credenza */}
|
||||||
|
<Credenza
|
||||||
|
open={isSetHeaderAuthOpen}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setIsSetHeaderAuthOpen(val);
|
||||||
|
if (!val) headerAuthForm.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{t("resourceHeaderAuthSetupTitle")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("resourceHeaderAuthSetupTitleDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<Form {...headerAuthForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={headerAuthForm.handleSubmit(
|
||||||
|
(data) => {
|
||||||
|
form.setValue("headerAuth", data);
|
||||||
|
setIsSetHeaderAuthOpen(false);
|
||||||
|
headerAuthForm.reset();
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="set-header-auth-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={headerAuthForm.control}
|
||||||
|
name="user"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("user")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
type="text"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={headerAuthForm.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("password")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={headerAuthForm.control}
|
||||||
|
name="extendedCompatibility"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id="header-auth-compatibility-toggle"
|
||||||
|
label={t(
|
||||||
|
"headerAuthCompatibility"
|
||||||
|
)}
|
||||||
|
info={t(
|
||||||
|
"headerAuthCompatibilityInfo"
|
||||||
|
)}
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">{t("close")}</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button type="submit" form="set-header-auth-form">
|
||||||
|
{t("resourceHeaderAuthSubmit")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("resourceAuthMethods")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("resourcePolicyAuthMethodsDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
{/* Password row */}
|
||||||
|
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
|
||||||
|
<div
|
||||||
|
className={cn("flex items-center text-sm space-x-2", password && "text-green-500")}
|
||||||
|
>
|
||||||
|
<Key size="14" />
|
||||||
|
<span>
|
||||||
|
{t("resourcePasswordProtection", {
|
||||||
|
status: password
|
||||||
|
? t("enabled")
|
||||||
|
: t("disabled")
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={
|
||||||
|
password
|
||||||
|
? () => form.setValue("password", null)
|
||||||
|
: () => setIsSetPasswordOpen(true)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{password
|
||||||
|
? t("passwordRemove")
|
||||||
|
: t("passwordAdd")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pincode row */}
|
||||||
|
<div className="flex items-center justify-between border rounded-md p-2">
|
||||||
|
<div
|
||||||
|
className={cn("flex items-center space-x-2 text-sm", pincode && "text-green-500")}
|
||||||
|
>
|
||||||
|
<Binary size="14" />
|
||||||
|
<span>
|
||||||
|
{t("resourcePincodeProtection", {
|
||||||
|
status: pincode
|
||||||
|
? t("enabled")
|
||||||
|
: t("disabled")
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={
|
||||||
|
pincode
|
||||||
|
? () => form.setValue("pincode", null)
|
||||||
|
: () => setIsSetPincodeOpen(true)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pincode ? t("pincodeRemove") : t("pincodeAdd")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header auth row */}
|
||||||
|
<div className="flex items-center justify-between border rounded-md p-2">
|
||||||
|
<div
|
||||||
|
className={cn("flex items-center space-x-2 text-sm", headerAuth && "text-green-500")}
|
||||||
|
>
|
||||||
|
<Bot size="14" />
|
||||||
|
<span>
|
||||||
|
{headerAuth
|
||||||
|
? t(
|
||||||
|
"resourceHeaderAuthProtectionEnabled"
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"resourceHeaderAuthProtectionDisabled"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={
|
||||||
|
headerAuth
|
||||||
|
? () =>
|
||||||
|
form.setValue("headerAuth", null)
|
||||||
|
: () => setIsSetHeaderAuthOpen(true)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{headerAuth
|
||||||
|
? t("headerAuthRemove")
|
||||||
|
: t("headerAuthAdd")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
280
src/components/resource-policy/CreatePolicyForm.tsx
Normal file
280
src/components/resource-policy/CreatePolicyForm.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"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 { UserType } from "@server/types/UserTypes";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
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 { 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 { useMemo, useTransition } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { CreatePolicyUsersRolesSectionForm } from "./CreatePolicyUserRolesSectionForm";
|
||||||
|
import { CreatePolicyAuthMethodsSectionForm } from "./CreatePolicyAuthMethodsSectionForm";
|
||||||
|
import { CreatePolicyOtpEmailSectionForm } from "./CreatePolicyOtpEmailSectionForm";
|
||||||
|
import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm";
|
||||||
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
|
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||||
|
|
||||||
|
// ─── CreatePolicyForm ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type CreatePolicyFormProps = {};
|
||||||
|
|
||||||
|
export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||||
|
const { org } = useOrgContext();
|
||||||
|
const t = useTranslations();
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const [isSubmitting, startTransition] = useTransition();
|
||||||
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
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<PolicyFormValues>({
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const payload = form.getValues();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api
|
||||||
|
.post<AxiosResponse<ResourcePolicy>>(
|
||||||
|
`/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),
|
||||||
|
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) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("policyErrorCreate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
t("policyErrorCreateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.status === 201) {
|
||||||
|
const niceId = res.data.data.niceId;
|
||||||
|
router.push(
|
||||||
|
`/${org.org.orgId}/settings/policies/resource/${niceId}`
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: t("success"),
|
||||||
|
description: t("policyCreatedSuccess")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("policyErrorCreate"),
|
||||||
|
description: t("policyErrorCreateMessageDescription")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const policyTiers = tierMatrix[TierFeature.ResourcePolicies];
|
||||||
|
const isDisabled = !isPaidUser(policyTiers);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PaidFeaturesAlert tiers={policyTiers} />
|
||||||
|
<Form {...form}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isDisabled
|
||||||
|
? "pointer-events-none opacity-50"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsContainer>
|
||||||
|
{/* Name */}
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("resourcePolicyName")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("resourcePolicyNameDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("name")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder={t(
|
||||||
|
"resourcePolicyNamePlaceholder"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<CreatePolicyUsersRolesSectionForm
|
||||||
|
form={form}
|
||||||
|
allRoles={allRoles}
|
||||||
|
allUsers={allUsers}
|
||||||
|
allIdps={allIdps}
|
||||||
|
/>
|
||||||
|
<CreatePolicyAuthMethodsSectionForm form={form} />
|
||||||
|
<CreatePolicyOtpEmailSectionForm
|
||||||
|
form={form}
|
||||||
|
emailEnabled={env.email.emailEnabled}
|
||||||
|
/>
|
||||||
|
<CreatePolicyRulesSectionForm
|
||||||
|
form={form}
|
||||||
|
isMaxmindAvailable={isMaxmindAvailable}
|
||||||
|
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||||
|
/>
|
||||||
|
</SettingsContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex py-6 justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startTransition(onSubmit)}
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={isSubmitting || isDisabled}
|
||||||
|
>
|
||||||
|
{t("resourcePoliciesCreate")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"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 { 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
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
|
||||||
|
import { InfoIcon, Plus } from "lucide-react";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { type UseFormReturn, useForm, useWatch } from "react-hook-form";
|
||||||
|
|
||||||
|
// ─── CreatePolicyOtpEmailSectionForm ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export type CreatePolicyOtpEmailSectionFormProps = {
|
||||||
|
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||||
|
emailEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CreatePolicyOtpEmailSectionForm({
|
||||||
|
form: parentForm,
|
||||||
|
emailEnabled
|
||||||
|
}: CreatePolicyOtpEmailSectionFormProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [isExpanded, setIsExpanded] = 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 (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("otpEmailTitle")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("otpEmailTitleDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{t("resourcePolicyOtpEmailAdd")}
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("otpEmailTitle")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("otpEmailTitleDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
{!emailEnabled && (
|
||||||
|
<Alert variant="neutral" className="mb-4">
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
<AlertTitle className="font-semibold">
|
||||||
|
{t("otpEmailSmtpRequired")}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t("otpEmailSmtpRequiredDescription")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<SwitchInput
|
||||||
|
id="whitelist-toggle"
|
||||||
|
label={t("otpEmailWhitelist")}
|
||||||
|
defaultChecked={false}
|
||||||
|
onCheckedChange={(val) => {
|
||||||
|
form.setValue("emailWhitelistEnabled", val);
|
||||||
|
}}
|
||||||
|
disabled={!emailEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{whitelistEnabled && emailEnabled && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="emails"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<InfoPopup
|
||||||
|
text={t("otpEmailWhitelistList")}
|
||||||
|
info={t(
|
||||||
|
"otpEmailWhitelistListDescription"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<TagInput
|
||||||
|
{...field}
|
||||||
|
activeTagIndex={
|
||||||
|
activeEmailTagIndex
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
validateTag={(tag) => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("otpEmailEnterDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
1092
src/components/resource-policy/CreatePolicyRulesSectionForm.tsx
Normal file
1092
src/components/resource-policy/CreatePolicyRulesSectionForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,257 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
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,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@app/components/ui/select";
|
||||||
|
import { createPolicySchema, type PolicyFormValues } from ".";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { type UseFormReturn, useForm, useWatch } from "react-hook-form";
|
||||||
|
|
||||||
|
// ─── CreatePolicyUsersRolesSectionForm ────────────────────────────────────────
|
||||||
|
|
||||||
|
export type CreatePolicyUsersRolesSectionFormProps = {
|
||||||
|
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||||
|
allRoles: { id: string; text: string }[];
|
||||||
|
allUsers: { id: string; text: string }[];
|
||||||
|
allIdps: { id: number; text: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CreatePolicyUsersRolesSectionForm({
|
||||||
|
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,
|
||||||
|
name: "skipToIdpId"
|
||||||
|
});
|
||||||
|
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("resourceUsersRoles")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("resourcePolicyUsersRolesDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<SwitchInput
|
||||||
|
id="sso-toggle"
|
||||||
|
label={t("ssoUse")}
|
||||||
|
defaultChecked={ssoEnabled}
|
||||||
|
onCheckedChange={(val) => {
|
||||||
|
form.setValue("sso", val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ssoEnabled && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="roles"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col items-start">
|
||||||
|
<FormLabel>{t("roles")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<TagInput
|
||||||
|
{...field}
|
||||||
|
activeTagIndex={
|
||||||
|
activeRolesTagIndex
|
||||||
|
}
|
||||||
|
setActiveTagIndex={
|
||||||
|
setActiveRolesTagIndex
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
"accessRoleSelect2"
|
||||||
|
)}
|
||||||
|
size="sm"
|
||||||
|
tags={form.getValues().roles}
|
||||||
|
setTags={(newRoles) => {
|
||||||
|
form.setValue(
|
||||||
|
"roles",
|
||||||
|
newRoles as [
|
||||||
|
Tag,
|
||||||
|
...Tag[]
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
enableAutocomplete={true}
|
||||||
|
autocompleteOptions={allRoles}
|
||||||
|
allowDuplicates={false}
|
||||||
|
restrictTagsToAutocompleteOptions={
|
||||||
|
true
|
||||||
|
}
|
||||||
|
sortTags={true}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
{t("resourceRoleDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="users"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col items-start">
|
||||||
|
<FormLabel>{t("users")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<TagInput
|
||||||
|
{...field}
|
||||||
|
activeTagIndex={
|
||||||
|
activeUsersTagIndex
|
||||||
|
}
|
||||||
|
setActiveTagIndex={
|
||||||
|
setActiveUsersTagIndex
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
"accessUserSelect"
|
||||||
|
)}
|
||||||
|
size="sm"
|
||||||
|
tags={form.getValues().users}
|
||||||
|
setTags={(newUsers) => {
|
||||||
|
form.setValue(
|
||||||
|
"users",
|
||||||
|
newUsers as [
|
||||||
|
Tag,
|
||||||
|
...Tag[]
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
enableAutocomplete={true}
|
||||||
|
autocompleteOptions={allUsers}
|
||||||
|
allowDuplicates={false}
|
||||||
|
restrictTagsToAutocompleteOptions={
|
||||||
|
true
|
||||||
|
}
|
||||||
|
sortTags={true}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ssoEnabled && allIdps.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
{t("defaultIdentityProvider")}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === "none") {
|
||||||
|
form.setValue("skipToIdpId", null);
|
||||||
|
} else {
|
||||||
|
const id = parseInt(value);
|
||||||
|
form.setValue("skipToIdpId", id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={
|
||||||
|
selectedIdpId
|
||||||
|
? selectedIdpId.toString()
|
||||||
|
: "none"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full mt-1">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
"selectIdpPlaceholder"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">
|
||||||
|
{t("none")}
|
||||||
|
</SelectItem>
|
||||||
|
{allIdps.map((idp) => (
|
||||||
|
<SelectItem
|
||||||
|
key={idp.id}
|
||||||
|
value={idp.id.toString()}
|
||||||
|
>
|
||||||
|
{idp.text}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("defaultIdentityProviderDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,671 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
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 { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { createPolicySchema } from ".";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
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 {
|
||||||
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSlot
|
||||||
|
} from "@app/components/ui/input-otp";
|
||||||
|
|
||||||
|
import { Binary, Bot, Key, Plus } from "lucide-react";
|
||||||
|
|
||||||
|
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 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 EditPolicyAuthMethodsSectionForm({
|
||||||
|
readonly
|
||||||
|
}: {
|
||||||
|
readonly?: boolean;
|
||||||
|
}) {
|
||||||
|
const { policy } = useResourcePolicyContext();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(
|
||||||
|
createPolicySchema.pick({
|
||||||
|
password: true,
|
||||||
|
pincode: true,
|
||||||
|
headerAuth: true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const t = useTranslations();
|
||||||
|
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");
|
||||||
|
|
||||||
|
// 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
|
||||||
|
);
|
||||||
|
|
||||||
|
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() {
|
||||||
|
if (readonly) return;
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const payload = form.getValues();
|
||||||
|
|
||||||
|
const responseArray: Array<Promise<AxiosResponse<{}> | void>> = [];
|
||||||
|
|
||||||
|
if (typeof payload.password !== "undefined") {
|
||||||
|
responseArray.push(
|
||||||
|
api
|
||||||
|
.put<AxiosResponse<{}>>(
|
||||||
|
`/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<AxiosResponse<{}>>(
|
||||||
|
`/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<AxiosResponse<{}>>(
|
||||||
|
`/resource-policy/${policy.resourcePolicyId}/header-auth`,
|
||||||
|
{
|
||||||
|
headerAuth: payload.headerAuth
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("policyErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
t("policyErrorUpdateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const responseList = await Promise.all(responseArray);
|
||||||
|
|
||||||
|
if (responseList.every((res) => 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 (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("resourceAuthMethods")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("resourcePolicyAuthMethodsDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
{!readonly ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{t("resourcePolicyAuthMethodAdd")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground flex items-center h-full size-full bg-muted rounded-md px-8 py-6 border-dashed text-sm">
|
||||||
|
<p>{t("resourcePolicyAuthMethodsEmpty")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Password Credenza */}
|
||||||
|
<Credenza
|
||||||
|
open={isSetPasswordOpen}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setIsSetPasswordOpen(val);
|
||||||
|
if (!val) passwordForm.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{t("resourcePasswordSetupTitle")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("resourcePasswordSetupTitleDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<Form {...passwordForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={passwordForm.handleSubmit((data) => {
|
||||||
|
form.setValue("password", data);
|
||||||
|
setIsSetPasswordOpen(false);
|
||||||
|
passwordForm.reset();
|
||||||
|
})}
|
||||||
|
className="space-y-4"
|
||||||
|
id="set-password-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={passwordForm.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("password")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">{t("close")}</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button type="submit" form="set-password-form">
|
||||||
|
{t("resourcePasswordSubmit")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
|
||||||
|
{/* Pincode Credenza */}
|
||||||
|
<Credenza
|
||||||
|
open={isSetPincodeOpen}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setIsSetPincodeOpen(val);
|
||||||
|
if (!val) pincodeForm.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{t("resourcePincodeSetupTitle")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("resourcePincodeSetupTitleDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<Form {...pincodeForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={pincodeForm.handleSubmit((data) => {
|
||||||
|
form.setValue("pincode", data);
|
||||||
|
setIsSetPincodeOpen(false);
|
||||||
|
pincodeForm.reset();
|
||||||
|
})}
|
||||||
|
className="space-y-4"
|
||||||
|
id="set-pincode-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={pincodeForm.control}
|
||||||
|
name="pincode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("resourcePincode")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<InputOTP
|
||||||
|
autoComplete="false"
|
||||||
|
maxLength={6}
|
||||||
|
{...field}
|
||||||
|
>
|
||||||
|
<InputOTPGroup className="flex">
|
||||||
|
<InputOTPSlot
|
||||||
|
index={0}
|
||||||
|
obscured
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={1}
|
||||||
|
obscured
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={2}
|
||||||
|
obscured
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={3}
|
||||||
|
obscured
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={4}
|
||||||
|
obscured
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={5}
|
||||||
|
obscured
|
||||||
|
/>
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">{t("close")}</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button type="submit" form="set-pincode-form">
|
||||||
|
{t("resourcePincodeSubmit")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
|
||||||
|
{/* Header Auth Credenza */}
|
||||||
|
<Credenza
|
||||||
|
open={isSetHeaderAuthOpen}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setIsSetHeaderAuthOpen(val);
|
||||||
|
if (!val) headerAuthForm.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{t("resourceHeaderAuthSetupTitle")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("resourceHeaderAuthSetupTitleDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<Form {...headerAuthForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={headerAuthForm.handleSubmit(
|
||||||
|
(data) => {
|
||||||
|
form.setValue("headerAuth", data);
|
||||||
|
setIsSetHeaderAuthOpen(false);
|
||||||
|
headerAuthForm.reset();
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="set-header-auth-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={headerAuthForm.control}
|
||||||
|
name="user"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("user")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
type="text"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={headerAuthForm.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("password")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={headerAuthForm.control}
|
||||||
|
name="extendedCompatibility"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id="header-auth-compatibility-toggle"
|
||||||
|
label={t(
|
||||||
|
"headerAuthCompatibility"
|
||||||
|
)}
|
||||||
|
info={t(
|
||||||
|
"headerAuthCompatibilityInfo"
|
||||||
|
)}
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">{t("close")}</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button type="submit" form="set-header-auth-form">
|
||||||
|
{t("resourceHeaderAuthSubmit")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form action={formAction}>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("resourceAuthMethods")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("resourcePolicyAuthMethodsDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
{/* Password row */}
|
||||||
|
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center text-sm gap-x-2",
|
||||||
|
hasPassword && "text-green-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Key size="14" />
|
||||||
|
<span>
|
||||||
|
{t("resourcePasswordProtection", {
|
||||||
|
status: hasPassword
|
||||||
|
? t("enabled")
|
||||||
|
: t("disabled")
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={readonly}
|
||||||
|
onClick={
|
||||||
|
hasPassword
|
||||||
|
? () =>
|
||||||
|
form.setValue(
|
||||||
|
"password",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
: () =>
|
||||||
|
setIsSetPasswordOpen(true)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasPassword
|
||||||
|
? t("passwordRemove")
|
||||||
|
: t("passwordAdd")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pincode row */}
|
||||||
|
<div className="flex items-center justify-between border rounded-md p-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-x-2 text-sm",
|
||||||
|
hasPincode && "text-green-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Binary size="14" />
|
||||||
|
<span>
|
||||||
|
{t("resourcePincodeProtection", {
|
||||||
|
status: hasPincode
|
||||||
|
? t("enabled")
|
||||||
|
: t("disabled")
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={readonly}
|
||||||
|
onClick={
|
||||||
|
hasPincode
|
||||||
|
? () =>
|
||||||
|
form.setValue(
|
||||||
|
"pincode",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
: () =>
|
||||||
|
setIsSetPincodeOpen(true)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasPincode
|
||||||
|
? t("pincodeRemove")
|
||||||
|
: t("pincodeAdd")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header auth row */}
|
||||||
|
<div className="flex items-center justify-between border rounded-md p-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-x-2 text-sm",
|
||||||
|
hasHeaderAuth && "text-green-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Bot size="14" />
|
||||||
|
<span>
|
||||||
|
{hasHeaderAuth
|
||||||
|
? t(
|
||||||
|
"resourceHeaderAuthProtectionEnabled"
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"resourceHeaderAuthProtectionDisabled"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={readonly}
|
||||||
|
onClick={
|
||||||
|
hasHeaderAuth
|
||||||
|
? () =>
|
||||||
|
form.setValue(
|
||||||
|
"headerAuth",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
: () =>
|
||||||
|
setIsSetHeaderAuthOpen(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasHeaderAuth
|
||||||
|
? t("headerAuthRemove")
|
||||||
|
: t("headerAuthAdd")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={readonly || isSubmitting}
|
||||||
|
>
|
||||||
|
{t("authMethodsSave")}
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/components/resource-policy/EditPolicyForm.tsx
Normal file
107
src/components/resource-policy/EditPolicyForm.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SettingsContainer } from "@app/components/Settings";
|
||||||
|
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
|
|
||||||
|
import { orgQueries } from "@app/lib/queries";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { EditPolicyAuthMethodsSectionForm } from "./EditPolicyAuthMethodsSectionForm";
|
||||||
|
import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm";
|
||||||
|
import { EditPolicyUsersRolesSectionForm } from "./EditPolicyUserRolesSectionForm";
|
||||||
|
import { EditPolicyOtpEmailSectionForm } from "./EditPolicyOtpEmailSectionForm";
|
||||||
|
import { EditPolicyRulesSectionForm } from "./EditPolicyRulesSectionForm";
|
||||||
|
|
||||||
|
// ─── EditPolicyForm ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type EditPolicyFormProps = {
|
||||||
|
hidePolicyNameForm?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
resourceId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EditPolicyForm({
|
||||||
|
hidePolicyNameForm,
|
||||||
|
readonly,
|
||||||
|
resourceId
|
||||||
|
}: EditPolicyFormProps) {
|
||||||
|
const { org } = useOrgContext();
|
||||||
|
const t = useTranslations();
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
// const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||||
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
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: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery(
|
||||||
|
orgQueries.identityProviders({
|
||||||
|
orgId: org.org.orgId,
|
||||||
|
useOrgOnlyIdp: env.app.identityProviderMode === "org"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (isLoadingOrgIdps) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
{!hidePolicyNameForm && (
|
||||||
|
<EditPolicyNameSectionForm readonly={readonly} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EditPolicyUsersRolesSectionForm
|
||||||
|
orgId={org.org.orgId}
|
||||||
|
allIdps={allIdps}
|
||||||
|
readonly={readonly}
|
||||||
|
resourceId={resourceId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditPolicyAuthMethodsSectionForm readonly={readonly} />
|
||||||
|
|
||||||
|
<EditPolicyOtpEmailSectionForm
|
||||||
|
emailEnabled={env.email.emailEnabled}
|
||||||
|
readonly={readonly}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditPolicyRulesSectionForm
|
||||||
|
isMaxmindAvailable={isMaxmindAvailable}
|
||||||
|
isMaxmindAsnAvailable={isMaxmindASNAvailable}
|
||||||
|
readonly={readonly}
|
||||||
|
resourceId={resourceId}
|
||||||
|
/>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
src/components/resource-policy/EditPolicyNameSectionForm.tsx
Normal file
155
src/components/resource-policy/EditPolicyNameSectionForm.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
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({ readonly }: { readonly?: boolean }) {
|
||||||
|
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() {
|
||||||
|
if (readonly) return;
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const payload = form.getValues();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api
|
||||||
|
.put<AxiosResponse<ResourcePolicy>>(
|
||||||
|
`/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 (
|
||||||
|
<Form {...form}>
|
||||||
|
<form action={formAction}>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("resourcePolicyName")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("resourcePolicyNameDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("name")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
disabled={readonly}
|
||||||
|
placeholder={t(
|
||||||
|
"resourcePolicyNamePlaceholder"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={readonly || isSubmitting}
|
||||||
|
>
|
||||||
|
{t("saveSettings")}
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
294
src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx
Normal file
294
src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
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";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
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 { 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 = {
|
||||||
|
emailEnabled: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EditPolicyOtpEmailSectionForm({
|
||||||
|
emailEnabled,
|
||||||
|
readonly
|
||||||
|
}: PolicyOtpEmailSectionProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const { policy } = useResourcePolicyContext();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
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() {
|
||||||
|
if (readonly) return;
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const payload = form.getValues();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api
|
||||||
|
.put<AxiosResponse<{}>>(
|
||||||
|
`/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) {
|
||||||
|
return (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("otpEmailTitle")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("otpEmailTitleDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
{!readonly ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{t("resourcePolicyOtpEmailAdd")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground flex items-center h-full size-full bg-muted rounded-md px-8 py-6 border-dashed text-sm">
|
||||||
|
<p>{t("resourcePolicyOtpEmpty")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form action={formAction}>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("otpEmailTitle")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("otpEmailTitleDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
{!emailEnabled && (
|
||||||
|
<Alert variant="neutral" className="mb-4">
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
<AlertTitle className="font-semibold">
|
||||||
|
{t("otpEmailSmtpRequired")}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t("otpEmailSmtpRequiredDescription")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<SwitchInput
|
||||||
|
id="whitelist-toggle"
|
||||||
|
label={t("otpEmailWhitelist")}
|
||||||
|
defaultChecked={whitelistEnabled}
|
||||||
|
onCheckedChange={(val) => {
|
||||||
|
form.setValue("emailWhitelistEnabled", val);
|
||||||
|
}}
|
||||||
|
disabled={readonly || !emailEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{whitelistEnabled && emailEnabled && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="emails"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<InfoPopup
|
||||||
|
text={t(
|
||||||
|
"otpEmailWhitelistList"
|
||||||
|
)}
|
||||||
|
info={t(
|
||||||
|
"otpEmailWhitelistListDescription"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<TagInput
|
||||||
|
{...field}
|
||||||
|
activeTagIndex={
|
||||||
|
activeEmailTagIndex
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
validateTag={(tag) => {
|
||||||
|
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) => {
|
||||||
|
if (!readonly) {
|
||||||
|
form.setValue(
|
||||||
|
"emails",
|
||||||
|
newEmails as [
|
||||||
|
Tag,
|
||||||
|
...Tag[]
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
allowDuplicates={false}
|
||||||
|
sortTags={true}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("otpEmailEnterDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsSectionForm>
|
||||||
|
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={
|
||||||
|
readonly || isSubmitting || !emailEnabled
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("otpEmailWhitelistSave")}
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
1343
src/components/resource-policy/EditPolicyRulesSectionForm.tsx
Normal file
1343
src/components/resource-policy/EditPolicyRulesSectionForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,530 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
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 { RolesSelector } from "@app/components/roles-selector";
|
||||||
|
import { UsersSelector } from "@app/components/users-selector";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
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 { resourceQueries } from "@app/lib/queries";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useActionState, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
|
|
||||||
|
// ─── PolicyUsersRolesSection ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type PolicyUsersRolesSectionProps = {
|
||||||
|
orgId: string;
|
||||||
|
allIdps: { id: number; text: string }[];
|
||||||
|
readonly?: boolean;
|
||||||
|
resourceId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EditPolicyUsersRolesSectionForm({
|
||||||
|
orgId,
|
||||||
|
allIdps,
|
||||||
|
readonly,
|
||||||
|
resourceId
|
||||||
|
}: PolicyUsersRolesSectionProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { policy } = useResourcePolicyContext();
|
||||||
|
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
// ── Resource overlay: fetch resource-specific roles & users ──────────────
|
||||||
|
const isResourceOverlay = resourceId !== undefined;
|
||||||
|
|
||||||
|
const { data: resourceRolesData } = useQuery({
|
||||||
|
...resourceQueries.resourceRoles({ resourceId: resourceId! }),
|
||||||
|
enabled: isResourceOverlay
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: resourceUsersData } = useQuery({
|
||||||
|
...resourceQueries.resourceUsers({ resourceId: resourceId! }),
|
||||||
|
enabled: isResourceOverlay
|
||||||
|
});
|
||||||
|
|
||||||
|
// IDs from the policy (locked — cannot be removed)
|
||||||
|
const policyRoleLockedIds = useMemo(
|
||||||
|
() => new Set(policy.roles.map((r) => r.roleId.toString())),
|
||||||
|
[policy.roles]
|
||||||
|
);
|
||||||
|
const policyUserLockedIds = useMemo(
|
||||||
|
() => new Set(policy.users.map((u) => u.userId)),
|
||||||
|
[policy.users]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Policy entries mapped to selector format
|
||||||
|
const policyRoleItems = useMemo(
|
||||||
|
() =>
|
||||||
|
policy.roles.map((r) => ({
|
||||||
|
id: r.roleId.toString(),
|
||||||
|
text: r.name
|
||||||
|
})),
|
||||||
|
[policy.roles]
|
||||||
|
);
|
||||||
|
const policyUserItems = useMemo(
|
||||||
|
() =>
|
||||||
|
policy.users.map((u) => ({
|
||||||
|
id: u.userId,
|
||||||
|
text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}`
|
||||||
|
})),
|
||||||
|
[policy.users]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track the initial resource-specific roles/users for diffing on save
|
||||||
|
const initialResourceRoleIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
const initialResourceUserIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Combined selected roles/users (policy + resource-specific)
|
||||||
|
const [combinedRoles, setCombinedRoles] = useState(policyRoleItems);
|
||||||
|
const [combinedUsers, setCombinedUsers] = useState(policyUserItems);
|
||||||
|
const [resourceRolesInitialized, setResourceRolesInitialized] =
|
||||||
|
useState(false);
|
||||||
|
const [resourceUsersInitialized, setResourceUsersInitialized] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isResourceOverlay || resourceRolesInitialized) return;
|
||||||
|
if (!resourceRolesData) return;
|
||||||
|
|
||||||
|
const resourceSpecific = resourceRolesData
|
||||||
|
.filter((r) => !policyRoleLockedIds.has(r.roleId.toString()))
|
||||||
|
.map((r) => ({ id: r.roleId.toString(), text: r.name }));
|
||||||
|
|
||||||
|
initialResourceRoleIdsRef.current = new Set(
|
||||||
|
resourceSpecific.map((r) => r.id)
|
||||||
|
);
|
||||||
|
setCombinedRoles([...policyRoleItems, ...resourceSpecific]);
|
||||||
|
setResourceRolesInitialized(true);
|
||||||
|
}, [
|
||||||
|
isResourceOverlay,
|
||||||
|
resourceRolesData,
|
||||||
|
resourceRolesInitialized,
|
||||||
|
policyRoleItems,
|
||||||
|
policyRoleLockedIds
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isResourceOverlay || resourceUsersInitialized) return;
|
||||||
|
if (!resourceUsersData) return;
|
||||||
|
|
||||||
|
const resourceSpecific = resourceUsersData
|
||||||
|
.filter((u) => !policyUserLockedIds.has(u.userId))
|
||||||
|
.map((u) => ({
|
||||||
|
id: u.userId,
|
||||||
|
text: `${getUserDisplayName({ email: u.email ?? undefined, username: u.username ?? undefined })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}`
|
||||||
|
}));
|
||||||
|
|
||||||
|
initialResourceUserIdsRef.current = new Set(
|
||||||
|
resourceSpecific.map((u) => u.id)
|
||||||
|
);
|
||||||
|
setCombinedUsers([...policyUserItems, ...resourceSpecific]);
|
||||||
|
setResourceUsersInitialized(true);
|
||||||
|
}, [
|
||||||
|
isResourceOverlay,
|
||||||
|
resourceUsersData,
|
||||||
|
resourceUsersInitialized,
|
||||||
|
policyUserItems,
|
||||||
|
policyUserLockedIds
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Standard policy form (non-overlay) ──────────────────────────────────
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(
|
||||||
|
createPolicySchema.pick({
|
||||||
|
sso: true,
|
||||||
|
skipToIdpId: true,
|
||||||
|
users: true,
|
||||||
|
roles: true
|
||||||
|
})
|
||||||
|
),
|
||||||
|
defaultValues: {
|
||||||
|
sso: policy.sso,
|
||||||
|
skipToIdpId: policy.idpId,
|
||||||
|
roles: policyRoleItems,
|
||||||
|
users: policyUserItems
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ssoEnabled = useWatch({ control: form.control, name: "sso" });
|
||||||
|
const selectedIdpId = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
name: "skipToIdpId"
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||||
|
const [isSavingOverlay, setIsSavingOverlay] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
if (isResourceOverlay) {
|
||||||
|
await saveResourceOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const payload = form.getValues();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api
|
||||||
|
.put<AxiosResponse<{}>>(
|
||||||
|
`/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")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveResourceOverlay() {
|
||||||
|
setIsSavingOverlay(true);
|
||||||
|
try {
|
||||||
|
// Compute which roles/users are resource-specific (non-locked)
|
||||||
|
const currentResourceRoleIds = new Set(
|
||||||
|
combinedRoles
|
||||||
|
.filter((r) => !policyRoleLockedIds.has(r.id))
|
||||||
|
.map((r) => r.id)
|
||||||
|
);
|
||||||
|
const currentResourceUserIds = new Set(
|
||||||
|
combinedUsers
|
||||||
|
.filter((u) => !policyUserLockedIds.has(u.id))
|
||||||
|
.map((u) => u.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialRoleIds = initialResourceRoleIdsRef.current;
|
||||||
|
const initialUserIds = initialResourceUserIdsRef.current;
|
||||||
|
|
||||||
|
const addedRoleIds = [...currentResourceRoleIds].filter(
|
||||||
|
(id) => !initialRoleIds.has(id)
|
||||||
|
);
|
||||||
|
const removedRoleIds = [...initialRoleIds].filter(
|
||||||
|
(id) => !currentResourceRoleIds.has(id)
|
||||||
|
);
|
||||||
|
const addedUserIds = [...currentResourceUserIds].filter(
|
||||||
|
(id) => !initialUserIds.has(id)
|
||||||
|
);
|
||||||
|
const removedUserIds = [...initialUserIds].filter(
|
||||||
|
(id) => !currentResourceUserIds.has(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...addedRoleIds.map((id) =>
|
||||||
|
api.post(`/resource/${resourceId}/roles/add`, {
|
||||||
|
roleId: Number(id)
|
||||||
|
})
|
||||||
|
),
|
||||||
|
...removedRoleIds.map((id) =>
|
||||||
|
api.post(`/resource/${resourceId}/roles/remove`, {
|
||||||
|
roleId: Number(id)
|
||||||
|
})
|
||||||
|
),
|
||||||
|
...addedUserIds.map((id) =>
|
||||||
|
api.post(`/resource/${resourceId}/users/add`, {
|
||||||
|
userId: id
|
||||||
|
})
|
||||||
|
),
|
||||||
|
...removedUserIds.map((id) =>
|
||||||
|
api.post(`/resource/${resourceId}/users/remove`, {
|
||||||
|
userId: id
|
||||||
|
})
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update refs to reflect new state
|
||||||
|
initialResourceRoleIdsRef.current = currentResourceRoleIds;
|
||||||
|
initialResourceUserIdsRef.current = currentResourceUserIds;
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("success"),
|
||||||
|
description: t("policyUpdatedSuccess")
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("policyErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
t("policyErrorUpdateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSavingOverlay(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
isResourceOverlay &&
|
||||||
|
(!resourceRolesInitialized || !resourceUsersInitialized);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form action={formAction}>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("resourceUsersRoles")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("resourcePolicyUsersRolesDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<SwitchInput
|
||||||
|
id="sso-toggle"
|
||||||
|
label={t("ssoUse")}
|
||||||
|
defaultChecked={ssoEnabled}
|
||||||
|
onCheckedChange={(val) => {
|
||||||
|
form.setValue("sso", val);
|
||||||
|
}}
|
||||||
|
disabled={readonly || isResourceOverlay}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ssoEnabled && (
|
||||||
|
<>
|
||||||
|
<FormItem className="flex flex-col items-start">
|
||||||
|
<FormLabel>{t("roles")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
{isResourceOverlay ? (
|
||||||
|
<RolesSelector
|
||||||
|
orgId={orgId}
|
||||||
|
selectedRoles={
|
||||||
|
combinedRoles
|
||||||
|
}
|
||||||
|
onSelectRoles={
|
||||||
|
setCombinedRoles
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
restrictAdminRole
|
||||||
|
lockedIds={
|
||||||
|
policyRoleLockedIds
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="roles"
|
||||||
|
render={({ field }) => (
|
||||||
|
<RolesSelector
|
||||||
|
orgId={orgId}
|
||||||
|
selectedRoles={
|
||||||
|
field.value
|
||||||
|
}
|
||||||
|
onSelectRoles={(
|
||||||
|
roles
|
||||||
|
) =>
|
||||||
|
form.setValue(
|
||||||
|
"roles",
|
||||||
|
roles
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={readonly}
|
||||||
|
restrictAdminRole
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
{t("resourceRoleDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
<FormItem className="flex flex-col items-start">
|
||||||
|
<FormLabel>{t("users")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
{isResourceOverlay ? (
|
||||||
|
<UsersSelector
|
||||||
|
orgId={orgId}
|
||||||
|
selectedUsers={
|
||||||
|
combinedUsers
|
||||||
|
}
|
||||||
|
onSelectUsers={
|
||||||
|
setCombinedUsers
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
lockedIds={
|
||||||
|
policyUserLockedIds
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="users"
|
||||||
|
render={({ field }) => (
|
||||||
|
<UsersSelector
|
||||||
|
orgId={orgId}
|
||||||
|
selectedUsers={
|
||||||
|
field.value
|
||||||
|
}
|
||||||
|
onSelectUsers={(
|
||||||
|
users
|
||||||
|
) =>
|
||||||
|
form.setValue(
|
||||||
|
"users",
|
||||||
|
users
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={readonly}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ssoEnabled && allIdps.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
{t("defaultIdentityProvider")}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
disabled={readonly || isResourceOverlay}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === "none") {
|
||||||
|
form.setValue(
|
||||||
|
"skipToIdpId",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const id = parseInt(value);
|
||||||
|
form.setValue(
|
||||||
|
"skipToIdpId",
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={
|
||||||
|
selectedIdpId
|
||||||
|
? selectedIdpId.toString()
|
||||||
|
: "none"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full mt-1">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
"selectIdpPlaceholder"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">
|
||||||
|
{t("none")}
|
||||||
|
</SelectItem>
|
||||||
|
{allIdps.map((idp) => (
|
||||||
|
<SelectItem
|
||||||
|
key={idp.id}
|
||||||
|
value={idp.id.toString()}
|
||||||
|
>
|
||||||
|
{idp.text}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"defaultIdentityProviderDescription"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={isSubmitting || isSavingOverlay}
|
||||||
|
disabled={
|
||||||
|
readonly ||
|
||||||
|
isSubmitting ||
|
||||||
|
isSavingOverlay ||
|
||||||
|
isLoading
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("resourceUsersRolesSubmit")}
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
1918
src/components/resource-policy/ResourcePolicySubForms.tsx
Normal file
1918
src/components/resource-policy/ResourcePolicySubForms.tsx
Normal file
File diff suppressed because it is too large
Load Diff
65
src/components/resource-policy/index.ts
Normal file
65
src/components/resource-policy/index.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// ─── 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<typeof createPolicySchema>;
|
||||||
|
|
||||||
|
export const addRuleSchema = z.object({
|
||||||
|
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
||||||
|
match: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
priority: z.coerce.number<number>().int().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LocalRule = {
|
||||||
|
ruleId: number;
|
||||||
|
action: "ACCEPT" | "DROP" | "PASS";
|
||||||
|
match: string;
|
||||||
|
value: string;
|
||||||
|
priority: number;
|
||||||
|
enabled: boolean;
|
||||||
|
new?: boolean;
|
||||||
|
updated?: boolean;
|
||||||
|
};
|
||||||
@@ -16,6 +16,7 @@ export type RolesSelectorProps = {
|
|||||||
restrictAdminRole?: boolean;
|
restrictAdminRole?: boolean;
|
||||||
mapRolesByName?: boolean;
|
mapRolesByName?: boolean;
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
|
lockedIds?: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RolesSelector({
|
export function RolesSelector({
|
||||||
@@ -25,7 +26,8 @@ export function RolesSelector({
|
|||||||
disabled,
|
disabled,
|
||||||
restrictAdminRole,
|
restrictAdminRole,
|
||||||
mapRolesByName,
|
mapRolesByName,
|
||||||
buttonText
|
buttonText,
|
||||||
|
lockedIds
|
||||||
}: RolesSelectorProps) {
|
}: RolesSelectorProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [roleSearchQuery, setRoleSearchQuery] = useState("");
|
const [roleSearchQuery, setRoleSearchQuery] = useState("");
|
||||||
@@ -76,6 +78,7 @@ export function RolesSelector({
|
|||||||
value={selectedRoles}
|
value={selectedRoles}
|
||||||
onChange={onSelectRoles}
|
onChange={onSelectRoles}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
lockedIds={lockedIds}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverAnchor,
|
PopoverAnchor,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger
|
PopoverTrigger
|
||||||
} from "../ui/popover";
|
} from "../ui/popover";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from "react";
|
||||||
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
|
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
|
||||||
import { TagList, TagListProps } from "./tag-list";
|
import { TagList, TagListProps } from "./tag-list";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
@@ -47,7 +53,7 @@ export const TagPopover: React.FC<TagPopoverProps> = ({
|
|||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
if (triggerContainerRef.current) {
|
if (triggerContainerRef.current) {
|
||||||
setPopoverWidth(triggerContainerRef.current.offsetWidth);
|
setPopoverWidth(triggerContainerRef.current.offsetWidth);
|
||||||
|
|||||||
@@ -18,12 +18,16 @@ export type UsersSelectorProps = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
selectedUsers?: SelectedUser[];
|
selectedUsers?: SelectedUser[];
|
||||||
onSelectUsers: (users: SelectedUser[]) => void;
|
onSelectUsers: (users: SelectedUser[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
lockedIds?: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function UsersSelector({
|
export function UsersSelector({
|
||||||
orgId,
|
orgId,
|
||||||
selectedUsers = [],
|
selectedUsers = [],
|
||||||
onSelectUsers
|
onSelectUsers,
|
||||||
|
disabled,
|
||||||
|
lockedIds
|
||||||
}: UsersSelectorProps) {
|
}: UsersSelectorProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [userSearchQuery, setUserSearchQuery] = useState("");
|
const [userSearchQuery, setUserSearchQuery] = useState("");
|
||||||
@@ -58,6 +62,8 @@ export function UsersSelector({
|
|||||||
options={usersShown}
|
options={usersShown}
|
||||||
value={selectedUsers}
|
value={selectedUsers}
|
||||||
onChange={onSelectUsers}
|
onChange={onSelectUsers}
|
||||||
|
disabled={disabled}
|
||||||
|
lockedIds={lockedIds}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ import type {
|
|||||||
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
|
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
|
||||||
import type {
|
import type {
|
||||||
GetResourceWhitelistResponse,
|
GetResourceWhitelistResponse,
|
||||||
|
GetResourcePoliciesResponse,
|
||||||
ListResourceNamesResponse,
|
ListResourceNamesResponse,
|
||||||
ListResourcesResponse
|
ListResourcesResponse,
|
||||||
|
ListResourceRolesResponse,
|
||||||
|
ListResourceRulesResponse,
|
||||||
|
ListResourceUsersResponse
|
||||||
} from "@server/routers/resource";
|
} from "@server/routers/resource";
|
||||||
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
|
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
|
||||||
import type { ListRolesResponse } from "@server/routers/role";
|
import type { ListRolesResponse } from "@server/routers/role";
|
||||||
@@ -33,6 +37,9 @@ import { remote } from "./api";
|
|||||||
import { durationToMs } from "./durationToMs";
|
import { durationToMs } from "./durationToMs";
|
||||||
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
||||||
import { StatusHistoryResponse } from "@server/lib/statusHistory";
|
import { StatusHistoryResponse } from "@server/lib/statusHistory";
|
||||||
|
import { wait } from "./wait";
|
||||||
|
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
|
||||||
|
import type { GetResourcePolicyResponse } from "@server/routers/policy";
|
||||||
|
|
||||||
export type ProductUpdate = {
|
export type ProductUpdate = {
|
||||||
link: string | null;
|
link: string | null;
|
||||||
@@ -540,6 +547,28 @@ export const orgQueries = {
|
|||||||
);
|
);
|
||||||
return res.data.data;
|
return res.data.data;
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
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<ListResourcePoliciesResponse>
|
||||||
|
>(`/org/${orgId}/resource-policies?${sp.toString()}`, {
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data.data.policies;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -597,7 +626,7 @@ export const resourceQueries = {
|
|||||||
queryKey: ["RESOURCES", resourceId, "USERS"] as const,
|
queryKey: ["RESOURCES", resourceId, "USERS"] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
const res = await meta!.api.get<
|
const res = await meta!.api.get<
|
||||||
AxiosResponse<ListSiteResourceUsersResponse>
|
AxiosResponse<ListResourceUsersResponse>
|
||||||
>(`/resource/${resourceId}/users`, { signal });
|
>(`/resource/${resourceId}/users`, { signal });
|
||||||
return res.data.data.users;
|
return res.data.data.users;
|
||||||
}
|
}
|
||||||
@@ -607,12 +636,23 @@ export const resourceQueries = {
|
|||||||
queryKey: ["RESOURCES", resourceId, "ROLES"] as const,
|
queryKey: ["RESOURCES", resourceId, "ROLES"] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
const res = await meta!.api.get<
|
const res = await meta!.api.get<
|
||||||
AxiosResponse<ListSiteResourceRolesResponse>
|
AxiosResponse<ListResourceRolesResponse>
|
||||||
>(`/resource/${resourceId}/roles`, { signal });
|
>(`/resource/${resourceId}/roles`, { signal });
|
||||||
|
|
||||||
return res.data.data.roles;
|
return res.data.data.roles;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
resourceRules: ({ resourceId }: { resourceId: number }) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["RESOURCES", resourceId, "RULES"] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<ListResourceRulesResponse>
|
||||||
|
>(`/resource/${resourceId}/rules`, { signal });
|
||||||
|
|
||||||
|
return res.data.data.rules;
|
||||||
|
}
|
||||||
|
}),
|
||||||
siteResourceUsers: ({ siteResourceId }: { siteResourceId: number }) =>
|
siteResourceUsers: ({ siteResourceId }: { siteResourceId: number }) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["SITE_RESOURCES", siteResourceId, "USERS"] as const,
|
queryKey: ["SITE_RESOURCES", siteResourceId, "USERS"] as const,
|
||||||
@@ -667,6 +707,17 @@ export const resourceQueries = {
|
|||||||
return res.data.data.whitelist;
|
return res.data.data.whitelist;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
policies: ({ resourceId }: { resourceId: number }) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["RESOURCES", resourceId, "POLICIES"] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<GetResourcePoliciesResponse>
|
||||||
|
>(`/resource/${resourceId}/policies`, { signal });
|
||||||
|
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
}),
|
||||||
listNamesPerOrg: (orgId: string) =>
|
listNamesPerOrg: (orgId: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["RESOURCES_NAMES", orgId] as const,
|
queryKey: ["RESOURCES_NAMES", orgId] as const,
|
||||||
|
|||||||
64
src/providers/ResourcePolicyProvider.tsx
Normal file
64
src/providers/ResourcePolicyProvider.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"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<GetResourcePolicyResponse>(serverPolicy);
|
||||||
|
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const updatePolicy = (
|
||||||
|
updatedPolicy: Partial<GetResourcePolicyResponse>
|
||||||
|
) => {
|
||||||
|
if (!policy) {
|
||||||
|
throw new Error(t("resourceErrorNoUpdate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
setPolicy((prev) => {
|
||||||
|
if (!prev) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
...updatedPolicy
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResourcePolicyContext value={{ policy, updatePolicy }}>
|
||||||
|
{children}
|
||||||
|
</ResourcePolicyContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResourcePolicyContextType = {
|
||||||
|
policy: GetResourcePolicyResponse;
|
||||||
|
updatePolicy: (updatedPolicy: Partial<GetResourcePolicyResponse>) => 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user