Compare commits

..

8 Commits

Author SHA1 Message Date
dependabot[bot]
3a4b82acbc Bump the dev-minor-updates group across 1 directory with 10 updates
Bumps the dev-minor-updates group with 10 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@dotenvx/dotenvx](https://github.com/dotenvx/dotenvx) | `1.54.1` | `1.64.0` |
| [@tanstack/react-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/react-query-devtools) | `5.91.3` | `5.100.9` |
| [@types/express-session](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/express-session) | `1.18.2` | `1.19.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.3.5` | `25.6.0` |
| [@types/pg](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/pg) | `8.18.0` | `8.20.0` |
| [esbuild](https://github.com/evanw/esbuild) | `0.27.4` | `0.28.0` |
| [esbuild-node-externals](https://github.com/pradel/esbuild-node-externals) | `1.20.1` | `1.22.0` |
| [eslint](https://github.com/eslint/eslint) | `10.0.3` | `10.3.0` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.1.7` | `16.2.4` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.56.1` | `8.59.2` |



Updates `@dotenvx/dotenvx` from 1.54.1 to 1.64.0
- [Release notes](https://github.com/dotenvx/dotenvx/releases)
- [Changelog](https://github.com/dotenvx/dotenvx/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dotenvx/dotenvx/compare/v1.54.1...v1.64.0)

Updates `@tanstack/react-query-devtools` from 5.91.3 to 5.100.9
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query-devtools@5.100.9/packages/react-query-devtools)

Updates `@types/express-session` from 1.18.2 to 1.19.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/express-session)

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

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

Updates `esbuild` from 0.27.4 to 0.28.0
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.27.4...v0.28.0)

Updates `esbuild-node-externals` from 1.20.1 to 1.22.0
- [Release notes](https://github.com/pradel/esbuild-node-externals/releases)
- [Commits](https://github.com/pradel/esbuild-node-externals/compare/esbuild-node-externals-v1.20.1...esbuild-node-externals-v1.22.0)

Updates `eslint` from 10.0.3 to 10.3.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.0.3...v10.3.0)

Updates `eslint-config-next` from 16.1.7 to 16.2.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.4/packages/eslint-config-next)

Updates `typescript-eslint` from 8.56.1 to 8.59.2
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.2/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@dotenvx/dotenvx"
  dependency-version: 1.61.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@tanstack/react-query-devtools"
  dependency-version: 5.99.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@types/express-session"
  dependency-version: 1.19.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@types/node"
  dependency-version: 25.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@types/pg"
  dependency-version: 8.20.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: esbuild
  dependency-version: 0.28.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: esbuild-node-externals
  dependency-version: 1.22.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: eslint
  dependency-version: 10.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: eslint-config-next
  dependency-version: 16.2.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: typescript-eslint
  dependency-version: 8.58.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-06 01:37:18 +00:00
Owen Schwartz
432dc81875 Merge pull request #3006 from fosrl/dev
don't await second calculate func
2026-05-05 13:46:05 -07:00
Owen Schwartz
9b71c426c7 Merge pull request #3005 from fosrl/dev
1.18.2-s.4
2026-05-05 12:12:09 -07:00
Owen Schwartz
87e6c7ba36 Merge pull request #3003 from fosrl/dev
1.18.2-s.3
2026-05-05 10:54:48 -07:00
Owen Schwartz
9410a18404 Merge pull request #2997 from fosrl/dev
Translations
2026-05-04 11:49:26 -07:00
Owen Schwartz
23f4302186 Merge pull request #2995 from fosrl/dev
1.18.2-s.2
2026-05-04 11:43:24 -07:00
Owen Schwartz
fb4bda077b Merge pull request #2983 from fosrl/dev
1.18.2-s.1
2026-05-03 14:59:12 -07:00
Owen Schwartz
cf596d980f Merge pull request #2971 from fosrl/dev
1.18.2
2026-05-02 20:59:51 -07:00
99 changed files with 2751 additions and 14485 deletions

5
.gitignore vendored
View File

@@ -17,9 +17,9 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
*.db
*.sqlite*
*.sqlite
!Dockerfile.sqlite
*.sqlite3*
*.sqlite3
*.log
.machinelogs*.json
*-audit.json
@@ -54,4 +54,3 @@ hydrateSaas.ts
CLAUDE.md
drizzle.config.ts
server/setup/migrations.ts
solo.yml

View File

@@ -1,12 +0,0 @@
services:
mailer:
image: axllent/mailpit
ports:
- 8025:8025
- 1025:1025
volumes:
- mailpit-storage:/data
environment:
- MP_DATABASE=/data/mailpit.db
volumes:
mailpit-storage:

View File

@@ -204,33 +204,11 @@
"resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting resource",
"resourcePoliciesTitle": "Manage Resource Policies",
"resourcePoliciesAttachedResourcesColumnTitle": "Attached resources",
"resourcePoliciesAttachedResources": "{count} resource(s)",
"resourcePoliciesAttachedResourcesEmpty": "no resources",
"resourcePoliciesDescription": "Create and manage authentication policies to control access to your resources",
"resourcePoliciesSearch": "Search policies...",
"resourcePoliciesAdd": "Add Policy",
"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",
"protected": "Protected",
"notProtected": "Not Protected",
"resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.",
"resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?",
"resourcePolicyMessageRemove": "Once removed, the resource policy will no longer be accessible. All resources associated with the resource will be unlinked and left without authentication.",
"resourcePolicyQuestionRemove": "Are you sure you want to remove the resource policy from the organization?",
"resourceHTTP": "HTTPS Resource",
"resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.",
"resourceRaw": "Raw TCP/UDP Resource",
@@ -271,8 +249,6 @@
"resourceLearnRaw": "Learn how to configure TCP/UDP resources",
"resourceBack": "Back to Resources",
"resourceGoTo": "Go to Resource",
"resourcePolicyDelete": "Delete Resource Policy",
"resourcePolicyDeleteConfirm": "Confirm Delete Resource Policy",
"resourceDelete": "Delete Resource",
"resourceDeleteConfirm": "Confirm Delete Resource",
"visibility": "Visibility",
@@ -285,8 +261,6 @@
"rules": "Rules",
"resourceSettingDescription": "Configure the settings on the resource",
"resourceSetting": "{resourceName} Settings",
"resourcePolicySettingDescription": "Configure the settings on the resource policy",
"resourcePolicySetting": "{policyName} Settings",
"alwaysAllow": "Bypass Auth",
"alwaysDeny": "Block Access",
"passToAuth": "Pass to Auth",
@@ -757,16 +731,6 @@
"rulesNoOne": "No rules. Add a rule using the form.",
"rulesOrder": "Rules are evaluated by priority in ascending order.",
"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",
"resourceErrorCreateDescription": "An error occurred when creating the resource",
"resourceErrorCreateMessage": "Error creating resource:",
@@ -830,16 +794,6 @@
"pincodeAdd": "Add PIN Code",
"pincodeRemove": "Remove PIN Code",
"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",
"resourceAuthSettingsSave": "Saved successfully",
"resourceAuthSettingsSaveDescription": "Authentication settings have been saved",
@@ -875,12 +829,6 @@
"resourcePincodeSetupTitle": "Set Pincode",
"resourcePincodeSetupTitleDescription": "Set a pincode to protect this resource",
"resourceRoleDescription": "Admins can always access this resource.",
"resourcePolicySelectTitle": "Resource Access Policy",
"resourcePolicySelectDescription": "Select the resource policy type for authentication",
"resourcePolicyInline": "Inline Resource Policy",
"resourcePolicyInlineDescription": "Access Policy scoped to only this resource",
"resourcePolicyShared": "Shared Resource Policy",
"resourcePolicySharedDescription": "Access Policy shared accross multiple resources",
"resourceUsersRoles": "Access Controls",
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
"resourceUsersRolesSubmit": "Save Access Controls",
@@ -1410,8 +1358,6 @@
"sidebarResources": "Resources",
"sidebarProxyResources": "Public",
"sidebarClientResources": "Private",
"sidebarPolicies": "Policies",
"sidebarResourcePolicies": "Resources",
"sidebarAccessControl": "Access Control",
"sidebarLogsAndAnalytics": "Logs & Analytics",
"sidebarTeam": "Team",

View File

@@ -1356,7 +1356,7 @@
"sidebarSites": "Nœuds",
"sidebarApprovals": "Demandes d'approbation",
"sidebarResources": "Ressource",
"sidebarProxyResources": "Publiques",
"sidebarProxyResources": "Publique",
"sidebarClientResources": "Privé",
"sidebarAccessControl": "Contrôle d'accès",
"sidebarLogsAndAnalytics": "Journaux & Analytiques",
@@ -2458,8 +2458,8 @@
"manageUserDevicesDescription": "Voir et gérer les appareils que les utilisateurs utilisent pour se connecter en privé aux ressources",
"downloadClientBannerTitle": "Télécharger le client Pangolin",
"downloadClientBannerDescription": "Téléchargez le client Pangolin pour votre système afin de vous connecter au réseau Pangolin et accéder aux ressources de manière privée.",
"manageMachineClients": "Gérer les machines",
"manageMachineClientsDescription": "Créer et gérer les clients que les serveurs et systèmes utilisent pour se connecter en privé aux ressources",
"manageMachineClients": "Gérer les clients de la machine",
"manageMachineClientsDescription": "Créer et gérer des clients que les serveurs et les systèmes utilisent pour se connecter en privé aux ressources",
"machineClientsBannerTitle": "Serveurs & Systèmes automatisés",
"machineClientsBannerDescription": "Les clients de machine sont conçus pour les serveurs et les systèmes automatisés qui ne sont pas associés à un utilisateur spécifique. Ils s'authentifient avec un identifiant et une clé secrète, et peuvent être exécutés avec Pangolin CLI, Olm CLI ou Olm en tant que conteneur.",
"machineClientsBannerPangolinCLI": "Pangolin CLI",
@@ -3154,7 +3154,6 @@
"healthCheckTabAdvanced": "Avancé",
"healthCheckStrategyNotAvailable": "Cette stratégie n'est pas disponible. Veuillez contacter le service commercial pour activer cette fonctionnalité.",
"uptime30d": "Disponibilité (30j)",
"uptimeNoData": "Aucune donnée",
"idpAddActionCreateNew": "Créer un nouveau fournisseur d'identité",
"idpAddActionImportFromOrg": "Importer d'une autre organisation",
"idpImportDialogTitle": "Importer le fournisseur d'identité",

598
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -131,25 +131,25 @@
"zod-validation-error": "5.0.0"
},
"devDependencies": {
"@dotenvx/dotenvx": "1.54.1",
"@dotenvx/dotenvx": "1.64.0",
"@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/preview-server": "5.2.10",
"@tailwindcss/postcss": "4.2.2",
"@tanstack/react-query-devtools": "5.91.3",
"@tanstack/react-query-devtools": "5.100.9",
"@types/better-sqlite3": "7.6.13",
"@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.19",
"@types/crypto-js": "4.2.2",
"@types/d3": "7.4.3",
"@types/express": "5.0.6",
"@types/express-session": "1.18.2",
"@types/express-session": "1.19.0",
"@types/jmespath": "0.15.2",
"@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "9.0.10",
"@types/node": "25.3.5",
"@types/node": "25.6.0",
"@types/nodemailer": "7.0.11",
"@types/nprogress": "0.2.3",
"@types/pg": "8.18.0",
"@types/pg": "8.20.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/semver": "7.7.1",
@@ -160,10 +160,10 @@
"@types/yargs": "17.0.35",
"babel-plugin-react-compiler": "1.0.0",
"drizzle-kit": "0.31.10",
"esbuild": "0.27.4",
"esbuild-node-externals": "1.20.1",
"eslint": "10.0.3",
"eslint-config-next": "16.1.7",
"esbuild": "0.28.0",
"esbuild-node-externals": "1.22.0",
"eslint": "10.3.0",
"eslint-config-next": "16.2.4",
"postcss": "8.5.8",
"prettier": "3.8.1",
"react-email": "5.2.10",
@@ -171,10 +171,10 @@
"tsc-alias": "1.8.16",
"tsx": "4.21.0",
"typescript": "5.9.3",
"typescript-eslint": "8.56.1"
"typescript-eslint": "8.59.2"
},
"overrides": {
"esbuild": "0.27.4",
"esbuild": "0.28.0",
"dompurify": "3.3.2"
}
}

View File

@@ -5,7 +5,6 @@ import { and, eq, inArray } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import logger from "@server/logger";
export enum ActionsEnum {
createOrgUser = "createOrgUser",
@@ -153,21 +152,7 @@ export enum ActionsEnum {
createHealthCheck = "createHealthCheck",
updateHealthCheck = "updateHealthCheck",
deleteHealthCheck = "deleteHealthCheck",
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"
listHealthChecks = "listHealthChecks"
}
export async function checkUserActionPermission(
@@ -200,23 +185,6 @@ 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
const userActionPermission = await db
.select()
@@ -234,7 +202,20 @@ export async function checkUserActionPermission(
return true;
}
return false;
// 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);
return roleActionPermission.length > 0;
} catch (error) {
console.error("Error checking user action permission:", error);
throw createHttpError(

View File

@@ -1,12 +1,6 @@
import { join } from "path";
import { readFileSync } from "fs";
import {
clients,
db,
resourcePolicies,
resources,
siteResources
} from "@server/db";
import { clients, db, resources, siteResources } from "@server/db";
import { randomInt } from "crypto";
import { exitNodes, sites } from "@server/db";
import { eq, and } from "drizzle-orm";
@@ -113,35 +107,6 @@ 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(
orgId: string
): Promise<string> {

View File

@@ -110,16 +110,6 @@ export const sites = pgTable("sites", {
export const resources = pgTable("resources", {
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 })
.unique()
.notNull()
@@ -206,11 +196,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
}).notNull(),
name: varchar("name"),
hcEnabled: boolean("hcEnabled").notNull().default(false),
hcPath: varchar("hcPath"),
@@ -533,38 +521,6 @@ export const userResources = pgTable("userResources", {
.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", {
inviteId: varchar("inviteId").primaryKey(),
orgId: varchar("orgId")
@@ -630,40 +586,6 @@ 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", {
accessTokenId: varchar("accessTokenId").primaryKey(),
orgId: varchar("orgId")
@@ -757,43 +679,6 @@ export const resourceRules = pgTable("resourceRules", {
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", {
keyId: serial("keyId").primaryKey(),
key: varchar("key").notNull(),
@@ -1212,30 +1097,19 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
complete: boolean("complete").notNull().default(false)
});
export const statusHistory = pgTable(
"statusHistory",
{
id: serial("id").primaryKey(),
entityType: varchar("entityType").notNull(),
entityId: integer("entityId").notNull(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: varchar("status").notNull(),
timestamp: integer("timestamp").notNull()
},
(table) => [
index("idx_statusHistory_entity").on(
table.entityType,
table.entityId,
table.timestamp
),
index("idx_statusHistory_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export const statusHistory = pgTable("statusHistory", {
id: serial("id").primaryKey(),
entityType: varchar("entityType").notNull(),
entityId: integer("entityId").notNull(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: varchar("status").notNull(),
timestamp: integer("timestamp").notNull(),
}, (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 User = InferSelectModel<typeof users>;
@@ -1305,6 +1179,3 @@ export type RoundTripMessageTracker = InferSelectModel<
>;
export type Network = InferSelectModel<typeof networks>;
export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;

View File

@@ -17,13 +17,10 @@ import {
resourceHeaderAuth,
ResourceHeaderAuth,
resourceRules,
resourcePolicyRules,
resources,
roleResources,
rolePolicies,
sessions,
userResources,
userPolicies,
users,
ResourceHeaderAuthExtendedCompatibility,
resourceHeaderAuthExtendedCompatibility
@@ -157,126 +154,58 @@ export async function getRoleName(roleId: number): Promise<string | null> {
}
/**
* Check if role has access to resource (direct or via resource policy)
* Check if role has access to resource
*/
export async function getRoleResourceAccess(
resourceId: number,
roleIds: number[]
) {
const [direct, viaPolicies] = await Promise.all([
db
.select()
.from(roleResources)
.where(
and(
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)
const roleResourceAccess = await db
.select()
.from(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
inArray(roleResources.roleId, roleIds)
)
.where(
and(
eq(resources.resourceId, resourceId),
inArray(rolePolicies.roleId, roleIds)
)
)
]);
);
const combined = [...direct, ...viaPolicies];
return combined.length > 0 ? combined : null;
return roleResourceAccess.length > 0 ? roleResourceAccess : null;
}
/**
* Check if user has access to resource (direct or via resource policy)
* Check if user has direct access to resource
*/
export async function getUserResourceAccess(
userId: string,
resourceId: number
) {
const [direct, viaPolicies] = await Promise.all([
db
.select()
.from(userResources)
.where(
and(
eq(userResources.userId, userId),
eq(userResources.resourceId, resourceId)
)
const userResourceAccess = await db
.select()
.from(userResources)
.where(
and(
eq(userResources.userId, userId),
eq(userResources.resourceId, resourceId)
)
.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)
]);
)
.limit(1);
return direct[0] ?? viaPolicies[0] ?? null;
return userResourceAccess.length > 0 ? userResourceAccess[0] : null;
}
/**
* Get resource rules for a given resource (direct and via resource policy)
* Get resource rules for a given resource
*/
export async function getResourceRules(
resourceId: number
): Promise<ResourceRule[]> {
const [directRules, policyRules] = await Promise.all([
db
.select()
.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 rules = await db
.select()
.from(resourceRules)
.where(eq(resourceRules.resourceId, resourceId));
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[];
return rules;
}
/**

View File

@@ -121,16 +121,6 @@ export const sites = sqliteTable("sites", {
export const resources = sqliteTable("resources", {
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 })
.unique()
.notNull()
@@ -229,11 +219,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
}).notNull(),
name: text("name"),
hcEnabled: integer("hcEnabled", { mode: "boolean" })
.notNull()
@@ -921,47 +909,6 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", {
headerAuthHash: text("headerAuthHash").notNull()
});
export const resourcePolicyPincode = sqliteTable("resourcePolicyPincode", {
pincodeId: integer("pincodeId").primaryKey({ autoIncrement: true }),
pincodeHash: text("pincodeHash").notNull(),
digitLength: integer("digitLength").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyPassword = sqliteTable("resourcePolicyPassword", {
passwordId: integer("passwordId").primaryKey({ autoIncrement: true }),
passwordHash: text("passwordHash").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyHeaderAuth = sqliteTable(
"resourcePolicyHeaderAuth",
{
headerAuthId: integer("headerAuthId").primaryKey({
autoIncrement: true
}),
headerAuthHash: text("headerAuthHash").notNull(),
extendedCompatibility: integer("extendedCompatibility", {
mode: "boolean"
})
.notNull()
.default(true),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
}
);
export const resourceHeaderAuthExtendedCompatibility = sqliteTable(
"resourceHeaderAuthExtendedCompatibility",
{
@@ -1076,77 +1023,6 @@ export const resourceRules = sqliteTable("resourceRules", {
value: text("value").notNull()
});
export const rolePolicies = sqliteTable("rolePolicies", {
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const userPolicies = sqliteTable("userPolicies", {
userId: text("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyWhiteList = sqliteTable("resourcePolicyWhitelist", {
whitelistId: integer("id").primaryKey({ autoIncrement: true }),
email: text("email").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
ruleId: integer("ruleId").primaryKey({ autoIncrement: true }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
}),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull(),
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: text("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
value: text("value").notNull()
});
export const resourcePolicies = sqliteTable("resourcePolicies", {
resourcePolicyId: integer("resourcePolicyId").primaryKey(),
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
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", {
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
key: text("key").notNull(),
@@ -1320,30 +1196,19 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
});
export const statusHistory = sqliteTable(
"statusHistory",
{
id: integer("id").primaryKey({ autoIncrement: true }),
entityType: text("entityType").notNull(), // "site" | "healthCheck"
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
timestamp: integer("timestamp").notNull() // unix epoch seconds
},
(table) => [
index("idx_statusHistory_entity").on(
table.entityType,
table.entityId,
table.timestamp
),
index("idx_statusHistory_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export const statusHistory = sqliteTable("statusHistory", {
id: integer("id").primaryKey({ autoIncrement: true }),
entityType: text("entityType").notNull(), // "site" | "healthCheck"
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
timestamp: integer("timestamp").notNull(), // unix epoch seconds
}, (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 User = InferSelectModel<typeof users>;
@@ -1413,6 +1278,3 @@ export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker
>;
export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;

View File

@@ -24,8 +24,7 @@ export enum TierFeature {
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules",
WildcardSubdomain = "wildcardSubdomain",
ResourcePolicies = "resourcePolicies"
WildcardSubdomain = "wildcardSubdomain"
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -67,6 +66,5 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"]
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
};

File diff suppressed because it is too large Load Diff

View File

@@ -162,10 +162,9 @@ export const HeaderSchema = z.object({
});
// Schema for individual resource
export const PublicResourceSchema = z
export const ResourceSchema = z
.object({
name: z.string().optional(),
policy: z.string().optional(),
protocol: z.enum(["http", "tcp", "udp"]).optional(),
ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).optional(),
@@ -341,8 +340,7 @@ export const PublicResourceSchema = z
if (parts.includes("*", 1)) return false; // no further wildcards
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));
},
{
@@ -356,7 +354,7 @@ export function isTargetsOnlyResource(resource: any): boolean {
return Object.keys(resource).length === 1 && resource.targets;
}
export const PrivateResourceSchema = z
export const ClientResourceSchema = z
.object({
name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "http"]),
@@ -437,19 +435,19 @@ export const PrivateResourceSchema = z
export const ConfigSchema = z
.object({
"proxy-resources": z
.record(z.string(), PublicResourceSchema)
.record(z.string(), ResourceSchema)
.optional()
.prefault({}),
"public-resources": z
.record(z.string(), PublicResourceSchema)
.record(z.string(), ResourceSchema)
.optional()
.prefault({}),
"client-resources": z
.record(z.string(), PrivateResourceSchema)
.record(z.string(), ClientResourceSchema)
.optional()
.prefault({}),
"private-resources": z
.record(z.string(), PrivateResourceSchema)
.record(z.string(), ClientResourceSchema)
.optional()
.prefault({}),
sites: z.record(z.string(), SiteSchema).optional().prefault({})
@@ -474,13 +472,10 @@ export const ConfigSchema = z
}
return data as {
"proxy-resources": Record<
string,
z.infer<typeof PublicResourceSchema>
>;
"proxy-resources": Record<string, z.infer<typeof ResourceSchema>>;
"client-resources": Record<
string,
z.infer<typeof PrivateResourceSchema>
z.infer<typeof ClientResourceSchema>
>;
sites: Record<string, z.infer<typeof SiteSchema>>;
};
@@ -619,5 +614,5 @@ export const ConfigSchema = z
// Type inference from the schema
export type Site = z.infer<typeof SiteSchema>;
export type Target = z.infer<typeof TargetSchema>;
export type Resource = z.infer<typeof PublicResourceSchema>;
export type Resource = z.infer<typeof ResourceSchema>;
export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -28,159 +28,6 @@ export async function calculateUserClientsForOrgs(
trx?: Transaction
): Promise<void> {
const execute = async (transaction: Transaction) => {
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
const adminRoleCache = new Map<
string,
typeof roles.$inferSelect | null
>();
const exitNodesCache = new Map<
string,
Awaited<ReturnType<typeof listExitNodes>>
>();
const isOrgLicensedCache = new Map<string, boolean>();
const existingClientCache = new Map<
string,
typeof clients.$inferSelect | null
>();
const roleClientAccessCache = new Map<string, boolean>();
const userClientAccessCache = new Map<string, boolean>();
const getOrgOlmKey = (orgId: string, olmId: string) =>
`${orgId}:${olmId}`;
const getRoleClientKey = (roleId: number, clientId: number) =>
`${roleId}:${clientId}`;
const getUserClientKey = (cachedUserId: string, clientId: number) =>
`${cachedUserId}:${clientId}`;
const getOrg = async (orgId: string) => {
if (orgCache.has(orgId)) {
return orgCache.get(orgId) ?? null;
}
const [org] = await transaction
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
orgCache.set(orgId, org ?? null);
return org ?? null;
};
const getAdminRole = async (orgId: string) => {
if (adminRoleCache.has(orgId)) {
return adminRoleCache.get(orgId) ?? null;
}
const [adminRole] = await transaction
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
adminRoleCache.set(orgId, adminRole ?? null);
return adminRole ?? null;
};
const getExitNodes = async (orgId: string) => {
if (exitNodesCache.has(orgId)) {
return exitNodesCache.get(orgId)!;
}
const exitNodes = await listExitNodes(orgId);
exitNodesCache.set(orgId, exitNodes);
return exitNodes;
};
const getIsOrgLicensed = async (orgId: string) => {
if (isOrgLicensedCache.has(orgId)) {
return isOrgLicensedCache.get(orgId)!;
}
const isOrgLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.deviceApprovals
);
isOrgLicensedCache.set(orgId, isOrgLicensed);
return isOrgLicensed;
};
const getExistingClient = async (orgId: string, olmId: string) => {
const key = getOrgOlmKey(orgId, olmId);
if (existingClientCache.has(key)) {
return existingClientCache.get(key) ?? null;
}
const [existingClient] = await transaction
.select()
.from(clients)
.where(
and(
eq(clients.userId, userId),
eq(clients.orgId, orgId),
eq(clients.olmId, olmId)
)
)
.limit(1);
existingClientCache.set(key, existingClient ?? null);
return existingClient ?? null;
};
const hasRoleClientAccess = async (
roleId: number,
clientId: number
) => {
const key = getRoleClientKey(roleId, clientId);
if (roleClientAccessCache.has(key)) {
return roleClientAccessCache.get(key)!;
}
const [existingRoleClient] = await transaction
.select()
.from(roleClients)
.where(
and(
eq(roleClients.roleId, roleId),
eq(roleClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingRoleClient);
roleClientAccessCache.set(key, hasAccess);
return hasAccess;
};
const hasUserClientAccess = async (
cachedUserId: string,
clientId: number
) => {
const key = getUserClientKey(cachedUserId, clientId);
if (userClientAccessCache.has(key)) {
return userClientAccessCache.get(key)!;
}
const [existingUserClient] = await transaction
.select()
.from(userClients)
.where(
and(
eq(userClients.userId, cachedUserId),
eq(userClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingUserClient);
userClientAccessCache.set(key, hasAccess);
return hasAccess;
};
// Get all OLMs for this user
const userOlms = await transaction
.select()
@@ -207,9 +54,7 @@ export async function calculateUserClientsForOrgs(
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(eq(userOrgs.userId, userId));
const userOrgIds = [
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
];
const userOrgIds = [...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))];
const orgIdToRoleRows = new Map<
string,
(typeof userOrgRoleRows)[0][]
@@ -219,13 +64,6 @@ export async function calculateUserClientsForOrgs(
list.push(r);
orgIdToRoleRows.set(r.userOrgs.orgId, list);
}
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
orgRequiresDeviceApprovalRole.set(
orgId,
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
);
}
// For each OLM, ensure there's a client in each org the user is in
for (const olm of userOlms) {
@@ -233,7 +71,10 @@ export async function calculateUserClientsForOrgs(
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
const userOrg = roleRowsForOrg[0].userOrgs;
const org = await getOrg(orgId);
const [org] = await transaction
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
if (!org) {
logger.warn(
@@ -250,7 +91,11 @@ export async function calculateUserClientsForOrgs(
}
// Get admin role for this org (needed for access grants)
const adminRole = await getAdminRole(orgId);
const [adminRole] = await transaction
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (!adminRole) {
logger.warn(
@@ -260,50 +105,64 @@ export async function calculateUserClientsForOrgs(
}
// Check if a client already exists for this OLM+user+org combination
const existingClient = await getExistingClient(
orgId,
olm.olmId
);
const [existingClient] = await transaction
.select()
.from(clients)
.where(
and(
eq(clients.userId, userId),
eq(clients.orgId, orgId),
eq(clients.olmId, olm.olmId)
)
)
.limit(1);
if (existingClient) {
// Ensure admin role has access to the client
const hasRoleAccess = await hasRoleClientAccess(
adminRole.roleId,
existingClient.clientId
);
const [existingRoleClient] = await transaction
.select()
.from(roleClients)
.where(
and(
eq(roleClients.roleId, adminRole.roleId),
eq(
roleClients.clientId,
existingClient.clientId
)
)
)
.limit(1);
if (!hasRoleAccess) {
if (!existingRoleClient) {
await transaction.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: existingClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(
adminRole.roleId,
existingClient.clientId
),
true
);
logger.debug(
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
);
}
// Ensure user has access to the client
const hasUserAccess = await hasUserClientAccess(
userId,
existingClient.clientId
);
const [existingUserClient] = await transaction
.select()
.from(userClients)
.where(
and(
eq(userClients.userId, userId),
eq(
userClients.clientId,
existingClient.clientId
)
)
)
.limit(1);
if (!hasUserAccess) {
if (!existingUserClient) {
await transaction.insert(userClients).values({
userId,
clientId: existingClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, existingClient.clientId),
true
);
logger.debug(
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
);
@@ -316,7 +175,7 @@ export async function calculateUserClientsForOrgs(
}
// Get exit nodes for this org
const exitNodesList = await getExitNodes(orgId);
const exitNodesList = await listExitNodes(orgId);
if (exitNodesList.length === 0) {
logger.warn(
@@ -347,11 +206,14 @@ export async function calculateUserClientsForOrgs(
const niceId = await getUniqueClientName(orgId);
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
const isOrgLicensed = await isLicensedOrSubscribed(
userOrg.orgId,
tierMatrix.deviceApprovals
);
const requireApproval =
build !== "oss" &&
isOrgLicensed &&
orgRequiresDeviceApprovalRole.get(orgId) === true;
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval);
const newClientData: InferInsertModel<typeof clients> = {
userId,
@@ -370,10 +232,6 @@ export async function calculateUserClientsForOrgs(
.insert(clients)
.values(newClientData)
.returning();
existingClientCache.set(
getOrgOlmKey(orgId, olm.olmId),
newClient
);
// create approval request
if (requireApproval) {
@@ -399,20 +257,12 @@ export async function calculateUserClientsForOrgs(
roleId: adminRole.roleId,
clientId: newClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(adminRole.roleId, newClient.clientId),
true
);
// Grant user access to the client
await transaction.insert(userClients).values({
userId,
clientId: newClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, newClient.clientId),
true
);
logger.debug(
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`

View File

@@ -32,4 +32,3 @@ export * from "./verifySiteResourceAccess";
export * from "./logActionAudit";
export * from "./verifyOlmAccess";
export * from "./verifyLimits";
export * from "./verifyResourcePolicyAccess";

View File

@@ -16,4 +16,3 @@ export * from "./verifyApiKeyClientAccess";
export * from "./verifyApiKeySiteResourceAccess";
export * from "./verifyApiKeyIdpAccess";
export * from "./verifyApiKeyDomainAccess";
export * from "./verifyApiKeyResourcePolicyAccess";

View File

@@ -1,92 +0,0 @@
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"
)
);
}
}

View File

@@ -1,127 +0,0 @@
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"
)
);
}
}

View File

@@ -38,7 +38,7 @@ export function verifyUserCanSetUserOrgRoles() {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission to set user organization roles"
"User does not have permission perform this action"
)
);
} catch (error) {

View File

@@ -7,7 +7,6 @@ export enum OpenAPITags {
Org = "Organization",
PublicResource = "Public Resource",
PrivateResource = "Private Resource",
Policy = "Policy",
Role = "Role",
User = "User",
Invitation = "User Invitation",

View File

@@ -31,8 +31,6 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks";
import * as resource from "#private/routers/resource";
import * as policy from "#private/routers/policy";
import {
verifyOrgAccess,
@@ -46,8 +44,7 @@ import {
verifyUserCanSetUserOrgRoles,
verifySiteProvisioningKeyAccess,
verifyIsLoggedInUser,
verifyAdmin,
verifyResourcePolicyAccess
verifyAdmin
} from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions";
import {
@@ -385,39 +382,6 @@ authenticated.get(
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(
"/org/:orgId/approvals/:approvalId",
verifyValidLicense,

View File

@@ -45,11 +45,8 @@ import {
users,
userOrgs,
roleResources,
rolePolicies,
userResources,
userPolicies,
resourceRules,
resourcePolicyRules,
userOrgRoles,
roles
} from "@server/db";
@@ -433,10 +430,7 @@ hybridRouter.get(
);
// 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 {
@@ -537,10 +531,7 @@ hybridRouter.get(
wildcardCandidates.length > 0
? and(
eq(resources.wildcard, true),
inArray(
resources.fullDomain,
wildcardCandidates
)
inArray(resources.fullDomain, wildcardCandidates)
)
: sql`false`
)
@@ -554,10 +545,10 @@ hybridRouter.get(
if (
result &&
(await checkExitNodeOrg(
await checkExitNodeOrg(
remoteExitNode.exitNodeId,
result.resources.orgId
))
)
) {
// If the exit node is not allowed for the org, return an error
return next(
@@ -1141,43 +1132,22 @@ hybridRouter.get(
);
}
const [direct, viaPolicies] = await Promise.all([
db
.select()
.from(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
eq(roleResources.roleId, roleId)
)
const roleResourceAccess = await db
.select()
.from(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
eq(roleResources.roleId, roleId)
)
.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)
]);
)
.limit(1);
const result = direct[0] ?? viaPolicies[0] ?? null;
const result =
roleResourceAccess.length > 0 ? roleResourceAccess[0] : null;
return response<typeof roleResources.$inferSelect | null>(res, {
data: result as any,
data: result,
success: true,
error: false,
message: result
@@ -1252,44 +1222,21 @@ hybridRouter.get(
);
}
const [direct, viaPolicies] = await Promise.all([
db
.select({
resourceId: roleResources.resourceId,
roleId: roleResources.roleId
})
.from(roleResources)
.where(
and(
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 roleResourceAccess = await db
.select({
resourceId: roleResources.resourceId,
roleId: roleResources.roleId
})
.from(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
inArray(roleResources.roleId, roleIds)
)
);
const combined = [...direct, ...viaPolicies];
const result = combined.length > 0 ? combined : null;
const result =
roleResourceAccess.length > 0 ? roleResourceAccess : null;
return response<{ resourceId: number; roleId: number }[] | null>(
res,
@@ -1450,45 +1397,10 @@ hybridRouter.get(
);
}
const [directRules, policyRules] = await Promise.all([
db
.select()
.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)[];
const rules = await db
.select()
.from(resourceRules)
.where(eq(resourceRules.resourceId, resourceId));
// backward compatibility: COUNTRY -> GEOIP
// TODO: remove this after a few versions once all exit nodes are updated

View File

@@ -1,417 +0,0 @@
/*
* 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")
);
}
}

View File

@@ -1,107 +0,0 @@
/*
* 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")
);
}
}

View File

@@ -1,16 +0,0 @@
/*
* 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";

View File

@@ -1,271 +0,0 @@
/*
* 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")
);
}
}

View File

@@ -671,8 +671,7 @@ export async function verifyResourceSession(
resourceData.org
);
// this is query intensive so let it cache a little longer
localCache.set(userAccessCacheKey, allowedUserData, 12);
localCache.set(userAccessCacheKey, allowedUserData, 5);
}
if (
@@ -1004,7 +1003,11 @@ async function checkRules(
isIpInCidr(clientIp, rule.value)
) {
return rule.action as any;
} else if (clientIp && rule.match == "IP" && clientIp == rule.value) {
} else if (
clientIp &&
rule.match == "IP" &&
clientIp == rule.value
) {
return rule.action as any;
} else if (
path &&
@@ -1012,7 +1015,10 @@ async function checkRules(
isPathAllowed(rule.value, path)
) {
return rule.action as any;
} else if (clientIp && rule.match == "COUNTRY") {
} else if (
clientIp &&
rule.match == "COUNTRY"
) {
// COUNTRY=ALL should not affect local/private/CGNAT addresses.
if (
rule.value.toUpperCase() === "ALL" &&
@@ -1024,7 +1030,10 @@ async function checkRules(
if (await isIpInGeoIP(ipCC, rule.value)) {
return rule.action as any;
}
} else if (clientIp && rule.match == "ASN") {
} else if (
clientIp &&
rule.match == "ASN"
) {
// ASN=ALL/AS0 should not affect local/private/CGNAT addresses.
if (
(rule.value.toUpperCase() === "ALL" ||
@@ -1263,15 +1272,11 @@ export async function isIpInRegion(
if (region.id === checkRegionCode) {
for (const subregion of region.includes) {
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;
}
}
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;
}
@@ -1279,14 +1284,10 @@ export async function isIpInRegion(
for (const subregion of region.includes) {
if (subregion.id === checkRegionCode) {
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;
}
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;
}
}

View File

@@ -3,7 +3,6 @@ import config from "@server/lib/config";
import * as site from "./site";
import * as org from "./org";
import * as resource from "./resource";
import * as policy from "./policy";
import * as domain from "./domain";
import * as target from "./target";
import * as user from "./user";
@@ -43,8 +42,7 @@ import {
verifyUserIsOrgOwner,
verifySiteResourceAccess,
verifyOlmAccess,
verifyLimits,
verifyResourcePolicyAccess
verifyLimits
} from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions";
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
@@ -105,6 +103,7 @@ authenticated.put(
site.createSite
);
authenticated.get(
"/org/:orgId/sites",
verifyOrgAccess,
@@ -541,7 +540,6 @@ authenticated.get(
verifyUserHasAction(ActionsEnum.getResource),
resource.getResource
);
authenticated.post(
"/resource/:resourceId",
verifyResourceAccess,
@@ -648,29 +646,6 @@ authenticated.post(
logActionAudit(ActionsEnum.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(
// "/role/:roleId",
// verifyRoleAccess,
@@ -722,59 +697,6 @@ authenticated.post(
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(
`/resource/:resourceId/password`,
verifyResourceAccess,

View File

@@ -2,7 +2,6 @@ import * as site from "./site";
import * as org from "./org";
import * as blueprints from "./blueprints";
import * as resource from "./resource";
import * as policy from "./policy";
import * as domain from "./domain";
import * as target from "./target";
import * as user from "./user";
@@ -30,9 +29,7 @@ import {
verifyApiKeySiteResourceAccess,
verifyApiKeySetResourceClients,
verifyLimits,
verifyApiKeyDomainAccess,
verifyApiKeyResourcePolicyAccess,
verifyUserHasAction
verifyApiKeyDomainAccess
} from "@server/middlewares";
import HttpCode from "@server/types/HttpCode";
import { Router } from "express";
@@ -462,20 +459,6 @@ authenticated.get(
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(
"/resource/:resourceId",
verifyApiKeyResourceAccess,
@@ -485,13 +468,6 @@ authenticated.post(
resource.updateResource
);
authenticated.put(
"/resource-policy/:resourcePolicyId",
verifyApiKeyResourcePolicyAccess,
verifyApiKeyHasAction(ActionsEnum.updateResourcePolicy),
policy.updateResourcePolicy
);
authenticated.delete(
"/resource/:resourceId",
verifyApiKeyResourceAccess,
@@ -643,63 +619,6 @@ authenticated.post(
resource.setResourceUsers
);
authenticated.put(
"/resource-policy/:resourcePolicyId/access-control",
verifyApiKeyResourcePolicyAccess,
verifyApiKeyRoleAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourcePolicyUsers),
verifyUserHasAction(ActionsEnum.setResourcePolicyRoles),
logActionAudit(ActionsEnum.setResourcePolicyUsers),
logActionAudit(ActionsEnum.setResourcePolicyRoles),
policy.setResourcePolicyAccessControl
);
authenticated.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(
"/resource/:resourceId/roles/add",
verifyApiKeyResourceAccess,

View File

@@ -1,231 +0,0 @@
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")
);
}
}

View File

@@ -1,8 +0,0 @@
export * from "./getResourcePolicy";
export * from "./updateResourcePolicy";
export * from "./setResourcePolicyAccessControl";
export * from "./setResourcePolicyPassword";
export * from "./setResourcePolicyPincode";
export * from "./setResourcePolicyHeaderAuth";
export * from "./setResourcePolicyWhitelist";
export * from "./setResourcePolicyRules";

View File

@@ -1,237 +0,0 @@
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")
);
}
}

View File

@@ -1,117 +0,0 @@
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")
);
}
}

View File

@@ -1,106 +0,0 @@
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")
);
}
}

View File

@@ -1,106 +0,0 @@
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")
);
}
}

View File

@@ -1,167 +0,0 @@
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")
);
}
}

View File

@@ -1,132 +0,0 @@
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")
);
}
}

View File

@@ -1,157 +0,0 @@
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")
);
}
}

View File

@@ -1,19 +1,15 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { build } from "@server/build";
import { db, domainNamespaces, loginPage } from "@server/db";
import {
db,
loginPage,
domains,
orgDomains,
orgs,
Resource,
resources,
resourcePolicies,
roleResources,
rolePolicies,
roles,
userPolicies,
userResources,
domainNamespaces
userResources
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -24,18 +20,13 @@ import logger from "@server/logger";
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
import { OpenAPITags, registry } from "@server/openApi";
import { build } from "@server/build";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import {
validateAndConstructDomain,
checkWildcardDomainConflict
} from "@server/lib/domainUtils";
import { getUniqueResourceName } from "@server/db/names";
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import {
getUniqueResourceName,
getUniqueResourcePolicyName
} from "@server/db/names";
const createResourceParamsSchema = z.strictObject({
orgId: z.string()
@@ -320,46 +311,8 @@ async function createHttpResource(
let resource: Resource | undefined;
const niceId = await getUniqueResourceName(orgId);
const policyNiceId = await getUniqueResourcePolicyName(orgId);
await db.transaction(async (trx) => {
const adminRole = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (adminRole.length === 0) {
return next(
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
);
}
const [defaultPolicy] = await trx
.insert(resourcePolicies)
.values({
niceId: policyNiceId,
orgId,
name: `default policy for ${niceId}`,
sso: true,
scope: "resource"
})
.returning();
// make this policy visible by the admin role
await trx.insert(rolePolicies).values({
roleId: adminRole[0].roleId,
resourcePolicyId: defaultPolicy.resourcePolicyId
});
// make this policy visible by the current user
if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
await trx.insert(userPolicies).values({
userId: req.user?.userId!,
resourcePolicyId: defaultPolicy.resourcePolicyId
});
}
const newResource = await trx
.insert(resources)
.values({
@@ -375,11 +328,22 @@ async function createHttpResource(
stickySession: stickySession,
postAuthPath: postAuthPath,
wildcard,
health: "unknown",
defaultResourcePolicyId: defaultPolicy.resourcePolicyId
health: "unknown"
})
.returning();
const adminRole = await db
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (adminRole.length === 0) {
return next(
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
);
}
await trx.insert(roleResources).values({
roleId: adminRole[0].roleId,
resourceId: newResource[0].resourceId
@@ -405,7 +369,7 @@ async function createHttpResource(
);
}
if (build !== "oss") {
if (build != "oss") {
await createCertificate(domainId, fullDomain, db);
}
@@ -446,10 +410,22 @@ async function createRawResource(
let resource: Resource | undefined;
const niceId = await getUniqueResourceName(orgId);
const policyNiceId = await getUniqueResourcePolicyName(orgId);
await db.transaction(async (trx) => {
const adminRole = await trx
const newResource = await trx
.insert(resources)
.values({
niceId,
orgId,
name,
http,
protocol,
proxyPort
// enableProxy
})
.returning();
const adminRole = await db
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
@@ -461,44 +437,6 @@ 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({
roleId: adminRole[0].roleId,
resourceId: newResource[0].resourceId

View File

@@ -1,22 +1,17 @@
import { eq, inArray } from "drizzle-orm";
import {
db,
newts,
resourcePolicies,
resources,
sites,
targetHealthCheck,
targets
} 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 { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
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 response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers";
import { removeTargets } from "../newt/targets";
import { getAllowedIps } from "../target/helpers";
import { OpenAPITags, registry } from "@server/openApi";
// Define Zod schema for request parameters validation
const deleteResourceSchema = z.strictObject({
@@ -118,18 +113,6 @@ 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, {
data: null,
success: true,

View File

@@ -2,13 +2,13 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
resourcePolicies,
resourcePolicyHeaderAuth,
resourcePolicyPassword,
resourcePolicyPincode,
resourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility,
resourcePassword,
resourcePincode,
resources
} from "@server/db";
import { eq, or } from "drizzle-orm";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -60,53 +60,64 @@ export async function getResourceAuthInfo(
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] =
isGuidInteger && build === "saas"
? await buildQuery(
eq(resources.resourceId, Number(resourceGuid))
)
: await buildQuery(eq(resources.resourceGuid, resourceGuid));
? await db
.select()
.from(resources)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId)
)
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuth,
eq(
resourceHeaderAuth.resourceId,
resources.resourceId
)
)
.leftJoin(
resourceHeaderAuthExtendedCompatibility,
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
resources.resourceId
)
)
.where(eq(resources.resourceId, Number(resourceGuid)))
.limit(1)
: await db
.select()
.from(resources)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId)
)
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuth,
eq(
resourceHeaderAuth.resourceId,
resources.resourceId
)
)
.leftJoin(
resourceHeaderAuthExtendedCompatibility,
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
resources.resourceId
)
)
.where(eq(resources.resourceGuid, resourceGuid))
.limit(1);
const resource = result?.resources;
if (!resource) {
@@ -115,10 +126,11 @@ export async function getResourceAuthInfo(
);
}
const policy = result?.resourcePolicies;
const pincode = result?.resourcePolicyPincode;
const password = result?.resourcePolicyPassword;
const headerAuth = result?.resourcePolicyHeaderAuth;
const pincode = result?.resourcePincode;
const password = result?.resourcePassword;
const headerAuth = result?.resourceHeaderAuth;
const headerAuthExtendedCompatibility =
result?.resourceHeaderAuthExtendedCompatibility;
const url = resource.fullDomain
? `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
@@ -134,13 +146,13 @@ export async function getResourceAuthInfo(
pincode: pincode !== null,
headerAuth: headerAuth !== null,
headerAuthExtendedCompatibility:
headerAuth?.extendedCompatibility ?? false,
sso: policy?.sso ?? false,
headerAuthExtendedCompatibility !== null,
sso: resource.sso,
blockAccess: resource.blockAccess,
url: url ?? "",
wildcard: resource.wildcard ?? false,
fullDomain: resource.fullDomain,
whitelist: policy?.emailWhitelistEnabled ?? false,
whitelist: resource.emailWhitelistEnabled,
skipToIdpId: resource.skipToIdpId,
orgId: resource.orgId,
postAuthPath: resource.postAuthPath ?? null

View File

@@ -1,109 +0,0 @@
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")
);
}
}

View File

@@ -33,4 +33,3 @@ export * from "./removeUserFromResource";
export * from "./listAllResourceNames";
export * from "./removeEmailFromResourceWhitelist";
export * from "./getStatusHistory";
export * from "./getResourcePolicies";

View File

@@ -1,9 +1,9 @@
import {
db,
resourcePolicies,
resourcePolicyHeaderAuth,
resourcePolicyPassword,
resourcePolicyPincode,
resourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility,
resourcePassword,
resourcePincode,
resources,
roleResources,
sites,
@@ -163,10 +163,10 @@ function queryResourcesBase() {
name: resources.name,
ssl: resources.ssl,
fullDomain: resources.fullDomain,
passwordId: resourcePolicyPassword.passwordId,
sso: resourcePolicies.sso,
pincodeId: resourcePolicyPincode.pincodeId,
whitelist: resourcePolicies.emailWhitelistEnabled,
passwordId: resourcePassword.passwordId,
sso: resources.sso,
pincodeId: resourcePincode.pincodeId,
whitelist: resources.emailWhitelistEnabled,
http: resources.http,
protocol: resources.protocol,
proxyPort: resources.proxyPort,
@@ -174,45 +174,29 @@ function queryResourcesBase() {
domainId: resources.domainId,
niceId: resources.niceId,
wildcard: resources.wildcard,
health: resources.health,
headerAuthId: resourcePolicyHeaderAuth.headerAuthId,
headerAuthExtendedCompatibility:
resourcePolicyHeaderAuth.extendedCompatibility
headerAuthId: resourceHeaderAuth.headerAuthId,
headerAuthExtendedCompatibilityId:
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
health: resources.health
})
.from(resources)
.leftJoin(
resourcePolicies,
or(
eq(
resourcePolicies.resourcePolicyId,
resources.resourcePolicyId
),
eq(
resourcePolicies.resourcePolicyId,
resources.defaultResourcePolicyId
)
)
)
.leftJoin(
resourcePolicyPassword,
eq(
resourcePolicyPassword.resourcePolicyId,
resourcePolicies.resourcePolicyId
)
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.leftJoin(
resourcePolicyPincode,
eq(
resourcePolicyPincode.resourcePolicyId,
resourcePolicies.resourcePolicyId
)
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId)
)
.leftJoin(
resourcePolicyHeaderAuth,
resourceHeaderAuth,
eq(resourceHeaderAuth.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuthExtendedCompatibility,
eq(
resourcePolicyHeaderAuth.resourcePolicyId,
resourcePolicies.resourcePolicyId
resourceHeaderAuthExtendedCompatibility.resourceId,
resources.resourceId
)
)
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
@@ -222,10 +206,10 @@ function queryResourcesBase() {
)
.groupBy(
resources.resourceId,
resourcePolicies.resourcePolicyId,
resourcePolicyPassword.passwordId,
resourcePolicyPincode.pincodeId,
resourcePolicyHeaderAuth.headerAuthId
resourcePassword.passwordId,
resourcePincode.pincodeId,
resourceHeaderAuth.headerAuthId,
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
);
}
@@ -371,21 +355,21 @@ export async function listResources(
case "protected":
conditions.push(
or(
eq(resourcePolicies.sso, true),
eq(resourcePolicies.emailWhitelistEnabled, true),
not(isNull(resourcePolicyHeaderAuth.headerAuthId)),
not(isNull(resourcePolicyPincode.pincodeId)),
not(isNull(resourcePolicyPassword.passwordId))
eq(resources.sso, true),
eq(resources.emailWhitelistEnabled, true),
not(isNull(resourceHeaderAuth.headerAuthId)),
not(isNull(resourcePincode.pincodeId)),
not(isNull(resourcePassword.passwordId))
)
);
break;
case "not_protected":
conditions.push(
not(eq(resourcePolicies.sso, true)),
not(eq(resourcePolicies.emailWhitelistEnabled, true)),
isNull(resourcePolicyHeaderAuth.headerAuthId),
isNull(resourcePolicyPincode.pincodeId),
isNull(resourcePolicyPassword.passwordId)
not(eq(resources.sso, true)),
not(eq(resources.emailWhitelistEnabled, true)),
isNull(resourceHeaderAuth.headerAuthId),
isNull(resourcePincode.pincodeId),
isNull(resourcePassword.passwordId)
);
break;
}
@@ -462,9 +446,9 @@ export async function listResources(
ssl: row.ssl,
fullDomain: row.fullDomain,
passwordId: row.passwordId,
sso: row.sso ?? false,
sso: row.sso,
pincodeId: row.pincodeId,
whitelist: row.whitelist ?? false,
whitelist: row.whitelist,
http: row.http,
protocol: row.protocol,
proxyPort: row.proxyPort,

View File

@@ -1,6 +1,3 @@
import type { Resource, ResourcePolicy } from "@server/db";
import type { PaginatedResponse } from "@server/types/Pagination";
export type GetMaintenanceInfoResponse = {
resourceId: number;
name: string;
@@ -11,19 +8,3 @@ export type GetMaintenanceInfoResponse = {
maintenanceMessage: 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>;
}>;

View File

@@ -1,23 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
domainNamespaces,
loginPage,
resourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility,
resourcePassword,
resourcePincode,
resourceRules,
resourceWhitelist
} from "@server/db";
import { db, domainNamespaces, loginPage } from "@server/db";
import {
domains,
Org,
orgDomains,
orgs,
Resource,
resourcePolicies,
resources
} from "@server/db";
import { eq, and, ne } from "drizzle-orm";
@@ -35,10 +24,7 @@ import {
import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi";
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 { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -82,8 +68,7 @@ const updateHttpResourceBodySchema = z
maintenanceTitle: z.string().max(255).nullable().optional(),
maintenanceMessage: z.string().max(2000).nullable().optional(),
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
postAuthPath: z.string().nullable().optional(),
resourcePolicyId: z.number().nullable().optional()
postAuthPath: z.string().nullable().optional()
})
.refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update"
@@ -180,8 +165,7 @@ const updateRawResourceBodySchema = z
stickySession: z.boolean().optional(),
enabled: z.boolean().optional(),
proxyProtocol: z.boolean().optional(),
proxyProtocolVersion: z.int().min(1).optional(),
resourcePolicyId: z.number().nullable().optional()
proxyProtocolVersion: z.int().min(1).optional()
})
.refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update"
@@ -317,42 +301,6 @@ async function updateHttpResource(
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) {
const [existingResource] = await db
.select()
@@ -378,6 +326,10 @@ async function updateHttpResource(
// Wildcard subdomains are a paid feature
if (updateData.subdomain && updateData.subdomain.includes("*")) {
const isLicensed = await isLicensedOrSubscribed(
resource.orgId,
tierMatrix.wildcardSubdomain
);
if (!isLicensed) {
return next(
createHttpError(
@@ -522,6 +474,10 @@ async function updateHttpResource(
headers = null;
}
const isLicensed = await isLicensedOrSubscribed(
resource.orgId,
tierMatrix.maintencePage
);
if (!isLicensed) {
updateData.maintenanceModeEnabled = undefined;
updateData.maintenanceModeType = undefined;
@@ -579,122 +535,38 @@ async function updateRawResource(
}
const updateData = parsedBody.data;
let updatedResource: Resource | null = null;
const [existingResource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resource.resourceId))
.limit(1);
await db.transaction(async (trx) => {
if (updateData.resourcePolicyId != null) {
const [existingPolicy] = await trx
.select()
.from(resourcePolicies)
.where(
eq(
resourcePolicies.resourcePolicyId,
updateData.resourcePolicyId
)
if (updateData.niceId) {
const [existingResource] = await db
.select()
.from(resources)
.where(
and(
eq(resources.niceId, updateData.niceId),
eq(resources.orgId, resource.orgId)
)
.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 (
existingResource &&
existingResource.resourceId !== resource.resourceId
) {
return next(
createHttpError(
HttpCode.CONFLICT,
`A resource with niceId "${updateData.niceId}" already exists`
)
);
}
}
if (updateData.niceId) {
const [existingResourceConflict] = await trx
.select()
.from(resources)
.where(
and(
eq(resources.niceId, updateData.niceId),
eq(resources.orgId, resource.orgId)
)
);
const updatedResource = await db
.update(resources)
.set(updateData)
.where(eq(resources.resourceId, resource.resourceId))
.returning();
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) {
if (updatedResource.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
@@ -704,7 +576,7 @@ async function updateRawResource(
}
return response(res, {
data: updatedResource,
data: updatedResource[0],
success: true,
error: false,
message: "Non-http Resource updated successfully",

View File

@@ -135,7 +135,7 @@ const listSitesSchema = z.object({
page: z.coerce
.number<string>() // for prettier formatting
.int()
.positive()
.min(0)
.optional()
.catch(1)
.default(1)

View File

@@ -47,7 +47,10 @@ export async function queryUser(orgId: string, userId: string) {
.from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(
and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId))
and(
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, orgId)
)
);
const isAdmin = roleRows.some((r) => r.isAdmin);
@@ -143,7 +146,7 @@ export async function getOrgUser(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission to get organization user details"
"User does not have permission perform this action"
)
);
}

View File

@@ -175,6 +175,26 @@ export default function GeneralPage() {
}, [variant]);
useEffect(() => {
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t("accessRoleErrorFetch"),
description: formatAxiosError(
e,
t("accessRoleErrorFetchDescription")
)
});
});
if (res?.status === 200) {
setRoles(res.data.data.roles);
}
}
const loadIdp = async (
availableRoles: { roleId: number; name: string }[]
) => {

View File

@@ -1,23 +0,0 @@
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>;
}

View File

@@ -1,60 +0,0 @@
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>
</>
);
}

View File

@@ -1,35 +0,0 @@
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 />
</>
);
}

View File

@@ -1,68 +0,0 @@
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
}}
/>
</>
);
}

View File

@@ -1,40 +1,44 @@
"use client";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import OrgRolesTagField from "@app/components/OrgRolesTagField";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { Checkbox } from "@app/components/ui/checkbox";
import OrgRolesTagField from "@app/components/OrgRolesTagField";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UserType } from "@server/types/UserTypes";
import { useTranslations } from "next-intl";
import { useParams } from "next/navigation";
import { useActionState, useEffect } from "react";
import { AxiosResponse } from "axios";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ListRolesResponse } from "@server/routers/role";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { useParams } from "next/navigation";
import { Button } from "@app/components/ui/button";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { UserType } from "@server/types/UserTypes";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build";
const accessControlsFormSchema = z.object({
username: z.string(),
@@ -55,6 +59,12 @@ export default function AccessControlsPage() {
const { orgId } = useParams();
const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [activeRoleTagIndex, setActiveRoleTagIndex] = useState<number | null>(
null
);
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.fullRbac);
@@ -87,21 +97,44 @@ export default function AccessControlsPage() {
text: r.name
}))
);
}, [user.userId, currentRoleIds.join(",")]);
useEffect(() => {
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t("accessRoleErrorFetch"),
description: formatAxiosError(
e,
t("accessRoleErrorFetchDescription")
)
});
});
if (res?.status === 200) {
setRoles(res.data.data.roles);
}
}
fetchRoles();
form.setValue("autoProvisioned", user.autoProvisioned || false);
}, [user.userId, user.autoProvisioned, currentRoleIds.join(",")]);
}, []);
const allRoleOptions = roles.map((role) => ({
id: role.roleId.toString(),
text: role.name
}));
const paywallMessage =
build === "saas"
? t("singleRolePerUserPlanNotice")
: t("singleRolePerUserEditionNotice");
const [, action, isSubmitting] = useActionState(onSubmit, null);
async function onSubmit() {
const isValid = await form.trigger();
if (!isValid) return;
const values = form.getValues();
async function onSubmit(values: z.infer<typeof accessControlsFormSchema>) {
if (values.roles.length === 0) {
toast({
variant: "destructive",
@@ -111,6 +144,7 @@ export default function AccessControlsPage() {
return;
}
setLoading(true);
try {
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const updateRoleRequest = supportsMultipleRolesPerUser
@@ -150,6 +184,7 @@ export default function AccessControlsPage() {
)
});
}
setLoading(false);
}
return (
@@ -168,7 +203,7 @@ export default function AccessControlsPage() {
<SettingsSectionForm>
<Form {...form}>
<form
action={action}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="access-controls-form"
>
@@ -191,7 +226,9 @@ export default function AccessControlsPage() {
<OrgRolesTagField
form={form}
name="roles"
orgId={orgId as string}
label={t("roles")}
placeholder={t("accessRoleSelect2")}
allRoleOptions={allRoleOptions}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
@@ -199,6 +236,9 @@ export default function AccessControlsPage() {
showMultiRolePaywallMessage
}
paywallMessage={paywallMessage}
loading={loading}
activeTagIndex={activeRoleTagIndex}
setActiveTagIndex={setActiveRoleTagIndex}
/>
{user.idpAutoProvision && (
@@ -237,8 +277,8 @@ export default function AccessControlsPage() {
<SettingsSectionFooter>
<Button
type="submit"
loading={isSubmitting}
disabled={isSubmitting}
loading={loading}
disabled={loading}
form="access-controls-form"
>
{t("accessControlsSubmit")}

View File

@@ -13,7 +13,7 @@ import { StrategyOption, StrategySelect } from "@app/components/StrategySelect";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { useParams, useRouter } from "next/navigation";
import { useActionState, useState } from "react";
import { useState } from "react";
import {
Form,
FormControl,
@@ -91,7 +91,7 @@ export default function Page() {
"internal"
);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(1);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [idps, setIdps] = useState<IdpOption[]>([]);
@@ -311,29 +311,10 @@ export default function Page() {
setUserOptions(options);
}, [idps, t]);
const [, submitInternalAction, isSubmittingInternal] = useActionState(
onSubmitInternal,
null
);
const [, submitGoogleAzureAction, isSubmittingGoogleAzure] = useActionState(
onSubmitGoogleAzure,
null
);
const [, submitGenericOidcAction, isSubmittingGenericOidc] = useActionState(
onSubmitGenericOidc,
null
);
const loading =
isSubmittingInternal ||
isSubmittingGoogleAzure ||
isSubmittingGenericOidc;
async function onSubmitInternal() {
const isValid = await internalForm.trigger();
if (!isValid) return;
const values = internalForm.getValues();
async function onSubmitInternal(
values: z.infer<typeof internalFormSchema>
) {
setLoading(true);
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
@@ -376,24 +357,25 @@ export default function Page() {
setExpiresInDays(parseInt(values.validForHours) / 24);
}
setLoading(false);
}
async function onSubmitGoogleAzure() {
const isValid = await googleAzureForm.trigger();
if (!isValid) return;
const values = googleAzureForm.getValues();
async function onSubmitGoogleAzure(
values: z.infer<typeof googleAzureFormSchema>
) {
const selectedUserOption = userOptions.find(
(opt) => opt.id === selectedOption
);
if (!selectedUserOption?.idpId) return;
setLoading(true);
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const res = await api
.put(`/org/${orgId}/user`, {
username: values.email,
username: values.email, // Use email as username for Google/Azure
email: values.email || undefined,
name: values.name,
type: "oidc",
@@ -419,19 +401,20 @@ export default function Page() {
});
router.push(`/${orgId}/settings/access/users`);
}
setLoading(false);
}
async function onSubmitGenericOidc() {
const isValid = await genericOidcForm.trigger();
if (!isValid) return;
const values = genericOidcForm.getValues();
async function onSubmitGenericOidc(
values: z.infer<typeof genericOidcFormSchema>
) {
const selectedUserOption = userOptions.find(
(opt) => opt.id === selectedOption
);
if (!selectedUserOption?.idpId) return;
setLoading(true);
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const res = await api
@@ -462,6 +445,8 @@ export default function Page() {
});
router.push(`/${orgId}/settings/access/users`);
}
setLoading(false);
}
return (
@@ -528,9 +513,9 @@ export default function Page() {
<SettingsSectionForm>
<Form {...internalForm}>
<form
action={
submitInternalAction
}
onSubmit={internalForm.handleSubmit(
onSubmitInternal
)}
className="space-y-4"
id="create-user-form"
>
@@ -610,7 +595,13 @@ export default function Page() {
<OrgRolesTagField
form={internalForm}
name="roles"
orgId={orgId as string}
label={t("roles")}
placeholder={t(
"accessRoleSelect2"
)}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
@@ -620,6 +611,13 @@ export default function Page() {
paywallMessage={
invitePaywallMessage
}
loading={loading}
activeTagIndex={
activeInviteRoleTagIndex
}
setActiveTagIndex={
setActiveInviteRoleTagIndex
}
/>
{env.email.emailEnabled && (
@@ -714,9 +712,9 @@ export default function Page() {
})() && (
<Form {...googleAzureForm}>
<form
action={
submitGoogleAzureAction
}
onSubmit={googleAzureForm.handleSubmit(
onSubmitGoogleAzure
)}
className="space-y-4"
id="create-user-form"
>
@@ -765,7 +763,13 @@ export default function Page() {
<OrgRolesTagField
form={googleAzureForm}
name="roles"
orgId={orgId as string}
label={t("roles")}
placeholder={t(
"accessRoleSelect2"
)}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
@@ -775,6 +779,13 @@ export default function Page() {
paywallMessage={
invitePaywallMessage
}
loading={loading}
activeTagIndex={
activeOidcRoleTagIndex
}
setActiveTagIndex={
setActiveOidcRoleTagIndex
}
/>
</form>
</Form>
@@ -797,9 +808,9 @@ export default function Page() {
})() && (
<Form {...genericOidcForm}>
<form
action={
submitGenericOidcAction
}
onSubmit={genericOidcForm.handleSubmit(
onSubmitGenericOidc
)}
className="space-y-4"
id="create-user-form"
>
@@ -877,7 +888,13 @@ export default function Page() {
<OrgRolesTagField
form={genericOidcForm}
name="roles"
orgId={orgId as string}
label={t("roles")}
placeholder={t(
"accessRoleSelect2"
)}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
@@ -887,6 +904,13 @@ export default function Page() {
paywallMessage={
invitePaywallMessage
}
loading={loading}
activeTagIndex={
activeOidcRoleTagIndex
}
setActiveTagIndex={
setActiveOidcRoleTagIndex
}
/>
</form>
</Form>

View File

@@ -13,7 +13,6 @@ import { Layout } from "@app/components/Layout";
import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv";
import { orgNavSections } from "@app/app/navigation";
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
export const dynamic = "force-dynamic";
@@ -49,7 +48,13 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const t = await getTranslations();
try {
const orgUser = await getCachedOrgUser(params.orgId, user.userId);
const getOrgUser = cache(() =>
internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${params.orgId}/user/${user.userId}`,
cookie
)
);
const orgUser = await getOrgUser();
if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) {
throw new Error(t("userErrorNotAdminOrOwner"));

View File

@@ -96,10 +96,10 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
title: t("authentication"),
href: `/{orgId}/settings/resources/proxy/{niceId}/authentication`
});
// navItems.push({
// title: t("rules"),
// href: `/{orgId}/settings/resources/proxy/{niceId}/rules`
// });
navItems.push({
title: t("rules"),
href: `/{orgId}/settings/resources/proxy/{niceId}/rules`
});
}
return (

View File

@@ -92,13 +92,7 @@ import { useTranslations } from "next-intl";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { toASCII } from "punycode";
import {
useMemo,
useState,
useCallback,
useTransition,
useEffect
} from "react";
import { useEffect, useMemo, useState, useCallback } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
@@ -224,7 +218,7 @@ export default function Page() {
>([]);
const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas");
const [createLoading, startTransition] = useTransition();
const [createLoading, setCreateLoading] = useState(false);
const [showSnippets, setShowSnippets] = useState(false);
const [niceId, setNiceId] = useState<string>("");
@@ -334,7 +328,7 @@ export default function Page() {
id: "raw" as ResourceType,
title: t("resourceRaw"),
description:
build === "saas"
build == "saas"
? t("resourceRawDescriptionCloud")
: t("resourceRawDescription")
}
@@ -479,6 +473,8 @@ export default function Page() {
);
async function onSubmit() {
setCreateLoading(true);
const baseData = baseForm.getValues();
const isHttp = baseData.http;
@@ -614,6 +610,8 @@ export default function Page() {
)
});
}
setCreateLoading(false);
}
useEffect(() => {
@@ -1467,7 +1465,7 @@ export default function Page() {
console.log(httpForm.getValues());
if (baseValid && settingsValid) {
startTransition(onSubmit);
onSubmit();
}
}}
loading={createLoading}

View File

@@ -11,7 +11,6 @@ import {
CreditCard,
Fingerprint,
Globe,
GlobeIcon,
GlobeLock,
KeyRound,
Laptop,
@@ -23,7 +22,6 @@ import {
ScanEye,
Server,
Settings,
ShieldIcon,
SquareMousePointer,
TicketCheck,
Unplug,
@@ -101,7 +99,7 @@ export const orgNavSections = (
href: "/{orgId}/settings/domains",
icon: <Globe className="size-4 flex-none" />
},
...(build === "saas"
...(build == "saas"
? [
{
title: "sidebarRemoteExitNodes",
@@ -136,24 +134,6 @@ 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
...((build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
build === "saas" ||

View File

@@ -28,14 +28,15 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { build } from "@server/build";
import { validateLocalPath } from "@app/lib/validateLocalPath";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types";
import { XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { validateLocalPath } from "@app/lib/validateLocalPath";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export type AuthPageCustomizationProps = {
orgId: string;

View File

@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
return (
<CredenzaContent
className={cn(
"flex min-h-0 max-h-[100dvh] flex-col overflow-y-auto md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
"flex min-h-0 max-h-[100dvh] flex-col overflow-hidden md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
className
)}
{...props}

View File

@@ -40,12 +40,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UserType } from "@server/types/UserTypes";
import { useQuery } from "@tanstack/react-query";
import {
ArrowDownIcon,
ChevronDownIcon,
ChevronsUpDown,
ExternalLink
} from "lucide-react";
import { ChevronsUpDown, ExternalLink } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
@@ -55,13 +50,11 @@ import {
formatMultiSitesSelectorLabel
} from "./multi-site-selector";
import type { Selectedsite } from "./site-selector";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { MachinesSelector } from "./machines-selector";
import DomainPicker from "@app/components/DomainPicker";
import { SwitchInput } from "@app/components/SwitchInput";
import CertificateStatus from "@app/components/CertificateStatus";
import { UsersSelector } from "./users-selector";
import { RolesSelector } from "./roles-selector";
import { build } from "@server/build";
// --- Helpers (shared) ---
@@ -1125,30 +1118,6 @@ export function InternalResourceForm({
}}
/>
</div>
<FormField
control={form.control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="internal-resource-ssl"
label={t(enableSslLabelKey)}
description={t(
enableSslDescriptionKey
)}
checked={!!field.value}
onCheckedChange={
field.onChange
}
disabled={
httpSectionDisabled
}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex items-start justify-between gap-4">
<FormField
control={form.control}
@@ -1515,22 +1484,40 @@ export function InternalResourceForm({
<FormItem className="flex flex-col items-start">
<FormLabel>{t("roles")}</FormLabel>
<FormControl>
<RolesSelector
selectedRoles={
field.value ?? []
<TagInput
{...field}
activeTagIndex={
activeRolesTagIndex
}
orgId={orgId}
onSelectRoles={(
newUsers
) => {
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder={t(
"accessRoleSelect2"
)}
size="sm"
tags={
form.getValues()
.roles ?? []
}
setTags={(newRoles) =>
form.setValue(
"roles",
newUsers as [
newRoles as [
Tag,
...Tag[]
]
);
}}
)
}
enableAutocomplete
autocompleteOptions={
allRoles
}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
@@ -1543,21 +1530,43 @@ export function InternalResourceForm({
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("users")}</FormLabel>
<UsersSelector
selectedUsers={
field.value ?? []
}
orgId={orgId}
onSelectUsers={(newUsers) => {
form.setValue(
"users",
newUsers as [
Tag,
...Tag[]
]
);
}}
/>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeUsersTagIndex
}
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder={t(
"accessUserSelect"
)}
tags={
form.getValues()
.users ?? []
}
size="sm"
setTags={(newUsers) =>
form.setValue(
"users",
newUsers as [
Tag,
...Tag[]
]
)
}
enableAutocomplete={true}
autocompleteOptions={
allUsers
}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
@@ -1571,20 +1580,73 @@ export function InternalResourceForm({
<FormLabel>
{t("machineClients")}
</FormLabel>
<MachinesSelector
selectedMachines={
field.value ?? []
}
orgId={orgId}
onSelectMachines={(
machines
) => {
form.setValue(
"clients",
machines
);
}}
/>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"justify-between w-full",
"text-muted-foreground pl-1.5"
)}
>
<span
className={cn(
"inline-flex items-center gap-1",
"overflow-x-auto"
)}
>
{(
field.value ??
[]
).map(
(
client
) => (
<span
key={
client.clientId
}
className={cn(
"bg-muted-foreground/20 font-normal text-foreground rounded-sm",
"py-1 px-1.5 text-xs"
)}
>
{
client.name
}
</span>
)
)}
<span className="pl-1 font-normal">
{t(
"accessClientSelect"
)}
</span>
</span>
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<MachinesSelector
selectedMachines={
field.value ??
[]
}
orgId={orgId}
onSelectMachines={(
machines
) => {
form.setValue(
"clients",
machines
);
}}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}

View File

@@ -8,42 +8,51 @@ import {
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl";
import type { Dispatch, SetStateAction } from "react";
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
import { RolesSelector, type SelectedRole } from "./roles-selector";
export type RoleTag = {
id: string;
text: string;
};
type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = {
form: Pick<
UseFormReturn<TFieldValues>,
"control" | "getValues" | "setValue"
>;
orgId: string;
form: Pick<UseFormReturn<TFieldValues>, "control" | "getValues" | "setValue">;
/** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */
name?: Path<TFieldValues>;
label?: string;
label: string;
placeholder: string;
allRoleOptions: Tag[];
supportsMultipleRolesPerUser: boolean;
showMultiRolePaywallMessage: boolean;
paywallMessage: string;
disabled?: boolean;
loading?: boolean;
activeTagIndex: number | null;
setActiveTagIndex: Dispatch<SetStateAction<number | null>>;
};
export default function OrgRolesTagField<TFieldValues extends FieldValues>({
form,
name = "roles" as Path<TFieldValues>,
label,
orgId,
placeholder,
allRoleOptions,
supportsMultipleRolesPerUser,
showMultiRolePaywallMessage,
paywallMessage,
disabled
loading = false,
activeTagIndex,
setActiveTagIndex
}: OrgRolesTagFieldProps<TFieldValues>) {
const t = useTranslations();
function setRoleTags(nextValue: SelectedRole[]) {
const prev = form.getValues(name) as SelectedRole[];
function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) {
const prev = form.getValues(name) as Tag[];
const nextValue =
typeof updater === "function" ? updater(prev) : updater;
const next = supportsMultipleRolesPerUser
? nextValue
: nextValue.length > 1
@@ -79,13 +88,22 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
name={name}
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{label ?? t("roles")}</FormLabel>
<FormLabel>{label}</FormLabel>
<FormControl>
<RolesSelector
orgId={orgId}
selectedRoles={field.value ?? []}
onSelectRoles={setRoleTags}
disabled={disabled}
<TagInput
{...field}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
placeholder={placeholder}
size="sm"
tags={field.value}
setTags={setRoleTags}
enableAutocomplete={true}
autocompleteOptions={allRoleOptions}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
disabled={loading}
/>
</FormControl>
{showMultiRolePaywallMessage && (

View File

@@ -193,17 +193,22 @@ export default function ProxyResourcesTable({
});
};
const deleteResource = async (resourceId: number) => {
await api.delete(`/resource/${resourceId}`).catch((e) => {
console.error(t("resourceErrorDelte"), e);
toast({
variant: "destructive",
title: t("resourceErrorDelte"),
description: formatAxiosError(e, t("resourceErrorDelte"))
const deleteResource = (resourceId: number) => {
api.delete(`/resource/${resourceId}`)
.catch((e) => {
console.error(t("resourceErrorDelte"), e);
toast({
variant: "destructive",
title: t("resourceErrorDelte"),
description: formatAxiosError(e, t("resourceErrorDelte"))
});
})
.then(() => {
startTransition(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
});
router.refresh();
setIsDeleteModalOpen(false);
};
async function toggleResourceEnabled(val: boolean, resourceId: number) {
@@ -765,11 +770,7 @@ export default function ProxyResourcesTable({
</div>
}
buttonText={t("resourceDeleteConfirm")}
onConfirm={async () =>
startTransition(() =>
deleteResource(selectedResource!.id)
)
}
onConfirm={async () => deleteResource(selectedResource!.id)}
string={selectedResource.name}
title={t("resourceDelete")}
/>

View File

@@ -1,311 +0,0 @@
"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"
/>
</>
);
}

View File

@@ -16,8 +16,6 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build";
import { RolesSelector } from "./roles-selector";
import { useParams } from "next/navigation";
export type RoleMappingRoleOption = {
roleId: number;
@@ -60,8 +58,9 @@ export default function RoleMappingConfigFields({
const t = useTranslations();
const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus();
const { orgId } = useParams();
const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState<
number | null
>(null);
const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac);
const showSingleRoleDisclaimer =
@@ -161,16 +160,23 @@ export default function RoleMappingConfigFields({
{roleMappingMode === "fixedRoles" && (
<div className="space-y-2 min-w-0 max-w-full">
<RolesSelector
selectedRoles={fixedRoleNames.map((name) => ({
<TagInput
tags={fixedRoleNames.map((name) => ({
id: name,
text: name
}))}
mapRolesByName
orgId={orgId as string}
onSelectRoles={(nextTags) => {
setTags={(nextTags) => {
const prevTags = fixedRoleNames.map((name) => ({
id: name,
text: name
}));
const next =
typeof nextTags === "function"
? nextTags(prevTags)
: nextTags;
let names = [
...new Set(nextTags.map((tag) => tag.text))
...new Set(next.map((tag) => tag.text))
];
if (!supportsMultipleRolesPerUser) {
@@ -192,6 +198,19 @@ export default function RoleMappingConfigFields({
onFixedRoleNamesChange(names);
}}
activeTagIndex={activeFixedRoleTagIndex}
setActiveTagIndex={setActiveFixedRoleTagIndex}
placeholder={
restrictToOrgRoles
? t("roleMappingFixedRolesPlaceholderSelect")
: t("roleMappingFixedRolesPlaceholderFreeform")
}
enableAutocomplete={restrictToOrgRoles}
autocompleteOptions={roleOptions}
restrictTagsToAutocompleteOptions={restrictToOrgRoles}
allowDuplicates={false}
sortTags={true}
size="sm"
/>
<FormDescription>
{showFreeformRoleNamesHint
@@ -333,7 +352,6 @@ function BuilderRuleRow({
}) {
const t = useTranslations();
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const { orgId } = useParams();
return (
<div
@@ -360,109 +378,67 @@ function BuilderRuleRow({
{t("roleMappingAssignRoles")}
</FormLabel>
<div className="min-w-0 max-w-full">
{restrictToOrgRoles ? (
<RolesSelector
selectedRoles={rule.roleNames.map((name) => ({
<TagInput
tags={rule.roleNames.map((name) => ({
id: name,
text: name
}))}
setTags={(nextTags) => {
const prevRoleTags = rule.roleNames.map((name) => ({
id: name,
text: name
}))}
buttonText={t("roleMappingAssignRoles")}
mapRolesByName
orgId={orgId as string}
onSelectRoles={(nextTags) => {
let names = [
...new Set(nextTags.map((tag) => tag.text))
];
}));
const next =
typeof nextTags === "function"
? nextTags(prevRoleTags)
: nextTags;
if (!supportsMultipleRolesPerUser) {
if (
names.length === 0 &&
rule.roleNames.length > 0
) {
onChange({
...rule,
roleNames: [
rule.roleNames[
rule.roleNames.length - 1
]!
]
});
return;
}
if (names.length > 1) {
names = [names[names.length - 1]!];
}
let names = [
...new Set(next.map((tag) => tag.text))
];
if (!supportsMultipleRolesPerUser) {
if (
names.length === 0 &&
rule.roleNames.length > 0
) {
onChange({
...rule,
roleNames: [
rule.roleNames[
rule.roleNames.length - 1
]!
]
});
return;
}
onChange({
...rule,
roleNames: names
});
}}
/>
) : (
<TagInput
tags={rule.roleNames.map((name) => ({
id: name,
text: name
}))}
setTags={(nextTags) => {
const prevRoleTags = rule.roleNames.map(
(name) => ({
id: name,
text: name
})
);
const next =
typeof nextTags === "function"
? nextTags(prevRoleTags)
: nextTags;
let names = [
...new Set(next.map((tag) => tag.text))
];
if (!supportsMultipleRolesPerUser) {
if (
names.length === 0 &&
rule.roleNames.length > 0
) {
onChange({
...rule,
roleNames: [
rule.roleNames[
rule.roleNames.length - 1
]!
]
});
return;
}
if (names.length > 1) {
names = [names[names.length - 1]!];
}
if (names.length > 1) {
names = [names[names.length - 1]!];
}
}
onChange({
...rule,
roleNames: names
});
}}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
placeholder={t(
"roleMappingAssignRolesPlaceholderFreeform"
)}
enableAutocomplete={false}
autocompleteOptions={roleOptions}
restrictTagsToAutocompleteOptions={false}
allowDuplicates={false}
sortTags={true}
size="sm"
styleClasses={{
inlineTagsContainer: "min-w-0 max-w-full"
}}
/>
)}
onChange({
...rule,
roleNames: names
});
}}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
placeholder={
restrictToOrgRoles
? t("roleMappingAssignRoles")
: t("roleMappingAssignRolesPlaceholderFreeform")
}
enableAutocomplete={restrictToOrgRoles}
autocompleteOptions={roleOptions}
restrictTagsToAutocompleteOptions={restrictToOrgRoles}
allowDuplicates={false}
sortTags={true}
size="sm"
styleClasses={{
inlineTagsContainer: "min-w-0 max-w-full"
}}
/>
</div>
{showFreeformRoleNamesHint && (
<p className="text-sm text-muted-foreground">

View File

@@ -61,19 +61,12 @@ export function SettingsSectionBody({
}
export function SettingsSectionFooter({
children,
className
children
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<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
)}
>
<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">
{children}
</div>
);

View File

@@ -25,15 +25,11 @@ export function StrategySelect<TValue extends string>({
value: controlledValue,
defaultValue,
onChange,
cols = 1
cols
}: StrategySelectProps<TValue>) {
const [uncontrolledSelected, setUncontrolledSelected] = useState<
TValue | undefined
>(defaultValue);
const [uncontrolledSelected, setUncontrolledSelected] = useState<TValue | undefined>(defaultValue);
const isControlled = controlledValue !== undefined;
const selected = isControlled
? (controlledValue ?? undefined)
: uncontrolledSelected;
const selected = isControlled ? (controlledValue ?? undefined) : uncontrolledSelected;
return (
<RadioGroup
@@ -43,11 +39,7 @@ export function StrategySelect<TValue extends string>({
if (!isControlled) setUncontrolledSelected(typedValue);
onChange?.(typedValue);
}}
style={{
// @ts-expect-error
"--cols": `repeat(${cols}, 1fr)`
}}
className="grid md:grid-cols-(--cols) gap-4"
className={`grid md:grid-cols-${cols ? cols : 1} gap-4`}
>
{options.map((option: StrategyOption<TValue>) => (
<label

View File

@@ -1,5 +1,18 @@
"use client";
import { useState, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { BellPlus, BellRing } from "lucide-react";
import {
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody
} from "@app/components/Settings";
import UptimeBar from "@app/components/UptimeBar";
import { Button } from "@app/components/ui/button";
import {
Credenza,
CredenzaBody,
@@ -10,32 +23,18 @@ import {
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import {
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import UptimeBar from "@app/components/UptimeBar";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { orgQueries } from "@app/lib/queries";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BellPlus, BellRing } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useState } from "react";
import { RolesSelector } from "./roles-selector";
import { UsersSelector } from "./users-selector";
interface UptimeAlertSectionProps {
orgId: string;
@@ -65,7 +64,12 @@ export default function UptimeAlertSection({
const [userTags, setUserTags] = useState<Tag[]>([]);
const [roleTags, setRoleTags] = useState<Tag[]>([]);
const [emailTags, setEmailTags] = useState<Tag[]>([]);
const [activeUserTagIndex, setActiveUserTagIndex] = useState<number | null>(
null
);
const [activeRoleTagIndex, setActiveRoleTagIndex] = useState<number | null>(
null
);
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
number | null
>(null);
@@ -76,6 +80,27 @@ export default function UptimeAlertSection({
enabled: isPaid
});
const { data: orgUsers = [] } = useQuery(orgQueries.users({ orgId }));
const { data: orgRoles = [] } = useQuery(orgQueries.roles({ orgId }));
const allUsers = useMemo(
() =>
orgUsers.map((u) => ({
id: String(u.id),
text: getUserDisplayName({
email: u.email,
name: u.name,
username: u.username
})
})),
[orgUsers]
);
const allRoles = useMemo(
() => orgRoles.map((r) => ({ id: String(r.roleId), text: r.name })),
[orgRoles]
);
const hasRules = (alertRules?.length ?? 0) > 0;
async function handleSubmit() {
@@ -202,16 +227,10 @@ export default function UptimeAlertSection({
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
<PaidFeaturesAlert
tiers={tierMatrix.alertingRules}
/>
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} />
<fieldset
disabled={!isPaid}
className={
!isPaid
? "opacity-50 pointer-events-none"
: ""
}
className={!isPaid ? "opacity-50 pointer-events-none" : ""}
>
<div className="space-y-4">
<div className="space-y-2">
@@ -221,53 +240,65 @@ export default function UptimeAlertSection({
<Input
id="alert-name"
value={name}
onChange={(e) =>
setName(e.target.value)
}
placeholder={t(
"uptimeAlertNamePlaceholder"
)}
onChange={(e) => setName(e.target.value)}
placeholder={t("uptimeAlertNamePlaceholder")}
/>
</div>
<div className="space-y-2">
<Label>
{t("alertingNotifyUsers")}
</Label>
<UsersSelector
selectedUsers={userTags}
orgId={orgId}
onSelectUsers={setUserTags}
<Label>{t("alertingNotifyUsers")}</Label>
<TagInput
activeTagIndex={activeUserTagIndex}
setActiveTagIndex={setActiveUserTagIndex}
placeholder={t("alertingSelectUsers")}
size="sm"
tags={userTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(userTags)
: newTags;
setUserTags(next as Tag[]);
}}
enableAutocomplete
autocompleteOptions={allUsers}
restrictTagsToAutocompleteOptions
allowDuplicates={false}
sortTags
/>
</div>
<div className="space-y-2">
<Label>
{t("alertingNotifyRoles")}
</Label>
<RolesSelector
selectedRoles={roleTags}
restrictAdminRole
orgId={orgId}
onSelectRoles={setRoleTags}
<Label>{t("alertingNotifyRoles")}</Label>
<TagInput
activeTagIndex={activeRoleTagIndex}
setActiveTagIndex={setActiveRoleTagIndex}
placeholder={t("alertingSelectRoles")}
size="sm"
tags={roleTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(roleTags)
: newTags;
setRoleTags(next as Tag[]);
}}
enableAutocomplete
autocompleteOptions={allRoles}
restrictTagsToAutocompleteOptions
allowDuplicates={false}
sortTags
/>
</div>
<div className="space-y-2">
<Label>
{t("uptimeAdditionalEmails")}
</Label>
<Label>{t("uptimeAdditionalEmails")}</Label>
<TagInput
activeTagIndex={activeEmailTagIndex}
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder={t(
"alertingEmailPlaceholder"
)}
setActiveTagIndex={setActiveEmailTagIndex}
placeholder={t("alertingEmailPlaceholder")}
size="sm"
tags={emailTags}
setTags={(newTags) => {
const next =
typeof newTags ===
"function"
typeof newTags === "function"
? newTags(emailTags)
: newTags;
setEmailTags(next as Tag[]);
@@ -275,9 +306,7 @@ export default function UptimeAlertSection({
allowDuplicates={false}
sortTags
validateTag={(tag) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
tag
)
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag)
}
delimiterList={[",", "Enter"]}
/>

View File

@@ -1,8 +1,5 @@
"use client";
import { ContactSalesBanner } from "@app/components/ContactSalesBanner";
import { StrategySelect } from "@app/components/StrategySelect";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import {
@@ -24,13 +21,11 @@ import {
import { Input } from "@app/components/ui/input";
import { Switch } from "@app/components/ui/switch";
import { Textarea } from "@app/components/ui/textarea";
import { Label } from "@app/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import {
Select,
SelectContent,
@@ -38,21 +33,24 @@ import {
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label";
import { StrategySelect } from "@app/components/StrategySelect";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import {
type AlertRuleFormAction,
type AlertRuleFormValues
} from "@app/lib/alertRuleForm";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { Bell, ChevronsUpDown, Globe, Plus, Trash2 } from "lucide-react";
import { ContactSalesBanner } from "@app/components/ContactSalesBanner";
import { Bell, Globe, ChevronsUpDown, Plus, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useRef, useState } from "react";
import type { Control, UseFormReturn } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form";
import { useDebounce } from "use-debounce";
import { RolesSelector } from "../roles-selector";
import { UsersSelector } from "../users-selector";
export function AddActionPanel({
onAdd
@@ -500,6 +498,12 @@ function NotifyActionFields({
const t = useTranslations();
const [emailActiveIdx, setEmailActiveIdx] = useState<number | null>(null);
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
number | null
>(null);
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
number | null
>(null);
const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery(
orgQueries.users({ orgId })
@@ -570,6 +574,14 @@ function NotifyActionFields({
hasResolvedTagsRef.current = true;
}, [isLoadingUsers, isLoadingRoles, allUsers, allRoles]);
const userTags = (useWatch({
control,
name: `actions.${index}.userTags`
}) ?? []) as Tag[];
const roleTags = (useWatch({
control,
name: `actions.${index}.roleTags`
}) ?? []) as Tag[];
const emailTags = (useWatch({
control,
name: `actions.${index}.emailTags`
@@ -584,16 +596,29 @@ function NotifyActionFields({
<FormItem className="flex flex-col items-start">
<FormLabel>{t("alertingNotifyUsers")}</FormLabel>
<FormControl>
<UsersSelector
selectedUsers={field.value ?? []}
orgId={orgId}
onSelectUsers={(newUsers) => {
<TagInput
{...field}
activeTagIndex={activeUsersTagIndex}
setActiveTagIndex={setActiveUsersTagIndex}
placeholder={t("alertingSelectUsers")}
size="sm"
tags={userTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(userTags)
: newTags;
form.setValue(
`actions.${index}.userTags`,
newUsers as [Tag, ...Tag[]],
next as Tag[],
{ shouldDirty: true }
);
}}
enableAutocomplete={true}
autocompleteOptions={allUsers}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
/>
</FormControl>
<FormMessage />
@@ -607,17 +632,29 @@ function NotifyActionFields({
<FormItem className="flex flex-col items-start">
<FormLabel>{t("alertingNotifyRoles")}</FormLabel>
<FormControl>
<RolesSelector
selectedRoles={field.value ?? []}
restrictAdminRole
orgId={orgId}
onSelectRoles={(newUsers) => {
<TagInput
{...field}
activeTagIndex={activeRolesTagIndex}
setActiveTagIndex={setActiveRolesTagIndex}
placeholder={t("alertingSelectRoles")}
size="sm"
tags={roleTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(roleTags)
: newTags;
form.setValue(
`actions.${index}.roleTags`,
newUsers as [Tag, ...Tag[]],
next as Tag[],
{ shouldDirty: true }
);
}}
enableAutocomplete={true}
autocompleteOptions={allRoles}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
/>
</FormControl>
<FormMessage />

View File

@@ -5,7 +5,7 @@ import { useMemo, useState } from "react";
import { useDebounce } from "use-debounce";
import { useTranslations } from "next-intl";
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
import { MultiSelectTags } from "./multi-select-tags";
export type SelectedMachine = Pick<
ListClientsResponse["clients"][number],
@@ -28,13 +28,11 @@ export function MachinesSelector({
const [debouncedValue] = useDebounce(machineSearchQuery, 150);
const perPage = 7;
const { data: machines = [] } = useQuery(
orgQueries.machineClients({ orgId, perPage, query: debouncedValue })
orgQueries.machineClients({ orgId, perPage: 10, query: debouncedValue })
);
// always include the selected machines in the list (if the user isn't searching)
// always include the selected machines in the list of machines shown (if the user isn't searching)
const machinesShown = useMemo(() => {
const allMachines: Array<SelectedMachine> = [...machines];
if (debouncedValue.trim().length === 0) {
@@ -46,32 +44,75 @@ export function MachinesSelector({
}
}
}
return allMachines;
}, [machines, selectedMachines, debouncedValue]);
// const selectedMachinesIds = new Set(
// selectedMachines.map((m) => m.clientId)
// );
return (
<MultiSelectTagInput
buttonText={t("accessClientSelect")}
searchPlaceholder={t("search")}
<MultiSelectTags
emptyPlaceholder={t("machineNotFound")}
searchQuery={machineSearchQuery}
onSearch={setMachineSearchQuery}
options={machinesShown.map((mc) => ({
id: mc.clientId.toString(),
text: mc.name
searchPlaceholder={t("machineSearch")}
value={selectedMachines.map((m) => ({
...m,
text: m.name,
id: m.clientId.toString()
}))}
value={selectedMachines.map((mc) => ({
id: mc.clientId.toString(),
text: mc.name
}))}
onChange={(newValues) => {
onSelectMachines(
newValues.map((v) => ({
clientId: Number(v.id),
name: v.text
}))
);
onChange={(values) => {
onSelectMachines(values);
}}
options={machinesShown.map((m) => ({
...m,
id: m.clientId.toString(),
text: m.name
}))}
onSearch={setMachineSearchQuery}
searchQuery={machineSearchQuery}
/>
// <Command shouldFilter={false}>
// <CommandInput
// placeholder={t("machineSearch")}
// value={machineSearchQuery}
// onValueChange={setMachineSearchQuery}
// />
// <CommandList>
// <CommandEmpty>{t("machineNotFound")}</CommandEmpty>
// <CommandGroup>
// {machinesShown.map((m) => (
// <CommandItem
// value={`${m.name}:${m.clientId}`}
// key={m.clientId}
// onSelect={() => {
// let newMachineClients = [];
// if (selectedMachinesIds.has(m.clientId)) {
// newMachineClients = selectedMachines.filter(
// (mc) => mc.clientId !== m.clientId
// );
// } else {
// newMachineClients = [
// ...selectedMachines,
// m
// ];
// }
// onSelectMachines(newMachineClients);
// }}
// >
// <CheckIcon
// className={cn(
// "mr-2 h-4 w-4",
// selectedMachinesIds.has(m.clientId)
// ? "opacity-100"
// : "opacity-0"
// )}
// />
// {`${m.name}`}
// </CommandItem>
// ))}
// </CommandGroup>
// </CommandList>
// </Command>
);
}

View File

@@ -0,0 +1,77 @@
import type { Ref } from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "./ui/command";
import { cn } from "@app/lib/cn";
import { CheckIcon } from "lucide-react";
export type TagValue = { text: string; id: string };
export type MultiSelectTagsProps<T extends TagValue> = {
emptyPlaceholder: string;
searchPlaceholder: string;
searchQuery?: string;
options: Array<T>;
value: Array<T>;
onChange: (newValue: Array<T>) => void;
onSearch: (query: string) => void;
ref?: Ref<HTMLButtonElement>;
};
export function MultiSelectTags<T extends TagValue>({
emptyPlaceholder,
searchPlaceholder,
searchQuery,
value,
options,
onSearch,
onChange
}: MultiSelectTagsProps<T>) {
const selectedValues = new Set(value.map((v) => v.id));
return (
<Command shouldFilter={false}>
<CommandInput
placeholder={searchPlaceholder}
value={searchQuery}
onValueChange={onSearch}
/>
<CommandList>
<CommandEmpty>{emptyPlaceholder}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
value={option.id}
key={option.id}
onSelect={() => {
let newValues = [];
if (selectedValues.has(option.id)) {
newValues = value.filter(
(v) => v.id !== option.id
);
} else {
newValues = [...value, option];
}
onChange(newValues);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
selectedValues.has(option.id)
? "opacity-100"
: "opacity-0"
)}
/>
{`${option.text}`}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}

View File

@@ -1,89 +0,0 @@
import type { Ref } from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "../ui/command";
import { cn } from "@app/lib/cn";
import { CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl";
export type TagValue = { text: string; id: string };
export type MultiSelectTagsProps<T extends TagValue> = {
emptyPlaceholder?: string;
searchPlaceholder?: string;
searchQuery?: string;
options: Array<T>;
value: Array<T>;
onChange: (newValue: Array<T>) => void;
onSearch: (query: string) => void;
ref?: Ref<HTMLButtonElement>;
disabled?: boolean;
lockedIds?: Set<string>;
};
export function MultiSelectContent<T extends TagValue>({
emptyPlaceholder,
searchPlaceholder,
searchQuery,
value,
options,
onSearch,
onChange,
lockedIds
}: MultiSelectTagsProps<T>) {
const t = useTranslations();
const selectedValues = new Set(value.map((v) => v.id));
return (
<Command shouldFilter={false}>
<CommandInput
placeholder={searchPlaceholder ?? t("search")}
value={searchQuery}
onValueChange={onSearch}
/>
<CommandList>
<CommandEmpty className="text-muted-foreground">
{emptyPlaceholder ?? t("noResults")}
</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isLocked = lockedIds?.has(option.id);
return (
<CommandItem
value={option.id}
key={option.id}
disabled={isLocked}
onSelect={() => {
if (isLocked) return;
let newValues = [];
if (selectedValues.has(option.id)) {
newValues = value.filter(
(v) => v.id !== option.id
);
} else {
newValues = [...value, option];
}
onChange(newValues);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
selectedValues.has(option.id)
? "opacity-100"
: "opacity-0"
)}
/>
{`${option.text}`}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
);
}

View File

@@ -1,117 +0,0 @@
import { buttonVariants } from "@app/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { ChevronDownIcon, LockIcon, XIcon } from "lucide-react";
import {
type MultiSelectTagsProps,
type TagValue,
MultiSelectContent
} from "./multi-select-content";
export interface MultiSelectInputProps<
T extends TagValue
> extends MultiSelectTagsProps<T> {
buttonText?: string;
lockedIds?: Set<string>;
}
export function MultiSelectTagInput<T extends TagValue>({
buttonText,
lockedIds,
...props
}: MultiSelectInputProps<T>) {
const selectedValues = new Set(props.value.map((v) => v.id));
return (
<Popover
onOpenChange={(open) => {
if (!open) {
// clear input when popover is closed
props.onSearch("");
}
}}
>
<PopoverTrigger asChild>
<div
role="combobox"
className={cn(
buttonVariants({
variant: "outline"
}),
"justify-between w-full inline-flex",
"text-muted-foreground pl-1.5 cursor-text",
"hover:bg-transparent hover:text-muted-foreground",
props.disabled && "pointer-events-none opacity-50"
)}
>
<span
className={cn(
"inline-flex items-center gap-1",
"overflow-x-auto"
)}
>
{props.value.map((option) => {
const isLocked = lockedIds?.has(option.id);
return (
<span
key={option.id}
className={cn(
"bg-muted-foreground/10 font-normal text-foreground rounded-sm",
"py-1 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5",
isLocked && "opacity-60"
)}
onClick={(e) => e.stopPropagation()}
>
{option.text}
{isLocked ? (
<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>
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
</div>
</PopoverTrigger>
<PopoverContent className="p-0">
<MultiSelectContent {...props} lockedIds={lockedIds} />
</PopoverContent>
</Popover>
);
}

View File

@@ -1,521 +0,0 @@
"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>
</>
);
}

View File

@@ -1,280 +0,0 @@
"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>
</>
);
}

View File

@@ -1,213 +0,0 @@
"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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,257 +0,0 @@
"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>
);
}

View File

@@ -1,671 +0,0 @@
"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>
</>
);
}

View File

@@ -1,107 +0,0 @@
"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>
);
}

View File

@@ -1,155 +0,0 @@
"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>
);
}

View File

@@ -1,294 +0,0 @@
"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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,530 +0,0 @@
"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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,65 +0,0 @@
// ─── 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;
};

View File

@@ -1,84 +0,0 @@
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { useDebounce } from "use-debounce";
import { useTranslations } from "next-intl";
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
export type SelectedRole = { id: string; text: string };
export type RolesSelectorProps = {
orgId: string;
selectedRoles?: SelectedRole[];
onSelectRoles: (roles: SelectedRole[]) => void;
disabled?: boolean;
restrictAdminRole?: boolean;
mapRolesByName?: boolean;
buttonText?: string;
lockedIds?: Set<string>;
};
export function RolesSelector({
orgId,
selectedRoles = [],
onSelectRoles,
disabled,
restrictAdminRole,
mapRolesByName,
buttonText,
lockedIds
}: RolesSelectorProps) {
const t = useTranslations();
const [roleSearchQuery, setRoleSearchQuery] = useState("");
const [debouncedValue] = useDebounce(roleSearchQuery, 150);
const { data: roles = [] } = useQuery(
orgQueries.roles({ orgId, perPage: 10, query: debouncedValue })
);
// always include the selected roles in the list (if the user isn't searching)
const rolesShown = useMemo(() => {
let allRoles: Array<SelectedRole & { isAdmin?: boolean }> = roles.map(
(r) => ({
id: mapRolesByName ? r.name : r.roleId.toString(),
text: r.name,
isAdmin: Boolean(r.isAdmin)
})
);
if (debouncedValue.trim().length === 0) {
for (const role of selectedRoles) {
if (!allRoles.find((r) => r.id === role.id)) {
allRoles.unshift(role);
}
}
}
if (restrictAdminRole) {
allRoles = allRoles.filter((role) => !role.isAdmin);
}
return allRoles;
}, [
roles,
selectedRoles,
debouncedValue,
restrictAdminRole,
mapRolesByName
]);
return (
<MultiSelectTagInput
buttonText={buttonText ?? t("alertingSelectRoles")}
searchQuery={roleSearchQuery}
onSearch={setRoleSearchQuery}
options={rolesShown}
value={selectedRoles}
onChange={onSelectRoles}
disabled={disabled}
lockedIds={lockedIds}
/>
);
}

View File

@@ -1,10 +1,4 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
import {
Command,
@@ -226,7 +220,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
>
<PopoverAnchor asChild>
<div
className="relative h-full flex items-center rounded-md border border-input bg-transparent pr-1"
className="relative h-full flex items-center rounded-md border border-input bg-transparent pr-3"
ref={triggerContainerRef}
>
{childrenWithProps}
@@ -266,7 +260,10 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
side="bottom"
align="start"
forceMount
className={cn("p-0", classStyleProps?.popoverContent)}
className={cn(
"p-0",
classStyleProps?.popoverContent
)}
style={{
width: `${popoverWidth}px`,
minWidth: `${popoverWidth}px`,
@@ -303,9 +300,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
key={option.id}
value={`${option.text} ${option.id}`}
onSelect={() => toggleTag(option)}
className={
classStyleProps?.commandItem
}
className={classStyleProps?.commandItem}
>
<Check
className={cn(

View File

@@ -85,8 +85,6 @@ export interface TagInputProps
autocompleteFilter?: (option: string) => boolean;
direction?: "row" | "column";
onInputChange?: (value: string) => void;
searchQuery?: string;
onSearchQueryChange?: (value: string) => void;
customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode;
onFocus?: React.FocusEventHandler<HTMLInputElement>;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
@@ -159,24 +157,10 @@ export function TagInput({ ref, ...props }: TagInputProps) {
disabled = false,
usePortal = false,
addOnPaste = false,
generateTagId = uuid,
searchQuery,
onSearchQueryChange
generateTagId = uuid
} = props;
const [inputValue, setInputValue] = React.useState("");
const isControlled = searchQuery !== undefined;
const effectiveQuery = isControlled ? searchQuery : inputValue;
const updateQuery = React.useCallback(
(action: React.SetStateAction<string>) => {
const resolved =
typeof action === "function" ? action(effectiveQuery) : action;
if (!isControlled) setInputValue(resolved);
onSearchQueryChange?.(resolved);
},
[isControlled, effectiveQuery, onSearchQueryChange]
);
const [tagCount, setTagCount] = React.useState(Math.max(0, tags.length));
const inputRef = React.useRef<HTMLInputElement>(null);
@@ -250,9 +234,9 @@ export function TagInput({ ref, ...props }: TagInputProps) {
);
}
});
updateQuery("");
setInputValue("");
} else {
updateQuery(newValue);
setInputValue(newValue);
}
onInputChange?.(newValue);
};
@@ -263,8 +247,8 @@ export function TagInput({ ref, ...props }: TagInputProps) {
};
const handleInputBlur = (event: React.FocusEvent<HTMLInputElement>) => {
if (addTagsOnBlur && effectiveQuery.trim()) {
const newTagText = effectiveQuery.trim();
if (addTagsOnBlur && inputValue.trim()) {
const newTagText = inputValue.trim();
if (validateTag && !validateTag(newTagText)) {
return;
@@ -289,7 +273,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
setTags([...tags, { id: newTagId, text: newTagText }]);
onTagAdd?.(newTagText);
setTagCount((prevTagCount) => prevTagCount + 1);
updateQuery("");
setInputValue("");
}
}
@@ -303,7 +287,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
: e.key === delimiter || e.key === Delimiter.Enter
) {
e.preventDefault();
const newTagText = effectiveQuery.trim();
const newTagText = inputValue.trim();
// Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true
if (
@@ -345,7 +329,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
onTagAdd?.(newTagText);
setTagCount((prevTagCount) => prevTagCount + 1);
}
updateQuery("");
setInputValue("");
} else {
switch (e.key) {
case "Delete":
@@ -435,6 +419,9 @@ export function TagInput({ ref, ...props }: TagInputProps) {
onClearAll?.();
};
// const filteredAutocompleteOptions = autocompleteFilter
// ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text))
// : autocompleteOptions;
const displayedTags = sortTags ? [...tags].sort() : tags;
const truncatedTags = truncate
@@ -449,15 +436,13 @@ export function TagInput({ ref, ...props }: TagInputProps) {
return (
<div
className={cn(
`w-full flex`,
!inlineTags && tags.length > 0 && "gap-3",
className={`w-full flex ${!inlineTags && tags.length > 0 ? "gap-3" : ""} ${
inputFieldPosition === "bottom"
? "flex-col"
: inputFieldPosition === "top"
? "flex-col-reverse"
: "flex-row"
)}
}`}
>
{!usePopoverForTags &&
(!inlineTags ? (
@@ -530,14 +515,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull
: placeholder
}
value={effectiveQuery}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
{...inputProps}
className={cn(
"border-0 px-2 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
"border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
// className,
styleClasses?.input
)}
@@ -559,17 +544,16 @@ export function TagInput({ ref, ...props }: TagInputProps) {
</div>
)
))}
{enableAutocomplete ? (
<div className="w-full">
<Autocomplete
tags={tags}
setTags={setTags}
setInputValue={updateQuery}
setInputValue={setInputValue}
autocompleteOptions={
(autocompleteOptions || []) as Tag[]
}
filterQuery={effectiveQuery}
filterQuery={inputValue}
setTagCount={setTagCount}
maxTags={maxTags}
onTagAdd={onTagAdd}
@@ -595,7 +579,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
// <CommandInput
// placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
// ref={inputRef}
// value={effectiveQuery}
// value={inputValue}
// disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
// onChangeCapture={handleInputChange}
// onKeyDown={handleKeyDown}
@@ -617,14 +601,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull
: placeholder
}
value={effectiveQuery}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
{...inputProps}
className={cn(
"border-0 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
// className,
styleClasses?.input
)}
@@ -678,7 +662,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
{/* <CommandInput
placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
ref={inputRef}
value={effectiveQuery}
value={inputValue}
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
onChangeCapture={handleInputChange}
onKeyDown={handleKeyDown}
@@ -701,14 +685,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull
: placeholder
}
value={effectiveQuery}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
{...inputProps}
className={cn(
"border-0 px-2 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
"border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
// className,
styleClasses?.input
)}
@@ -757,7 +741,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
{/* <CommandInput
placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
ref={inputRef}
value={effectiveQuery}
value={inputValue}
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
onChangeCapture={handleInputChange}
onKeyDown={handleKeyDown}
@@ -779,14 +763,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull
: placeholder
}
value={effectiveQuery}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
{...inputProps}
className={cn(
"border-0 px-2 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
"border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
// className,
styleClasses?.input
)}
@@ -822,7 +806,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull
: placeholder
}
value={effectiveQuery}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
@@ -882,7 +866,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull
: placeholder
}
value={effectiveQuery}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}

View File

@@ -1,16 +1,10 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger
} from "../ui/popover";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from "react";
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
import { TagList, TagListProps } from "./tag-list";
import { Button } from "../ui/button";
@@ -53,7 +47,7 @@ export const TagPopover: React.FC<TagPopoverProps> = ({
const t = useTranslations();
useLayoutEffect(() => {
useEffect(() => {
const handleResize = () => {
if (triggerContainerRef.current) {
setPopoverWidth(triggerContainerRef.current.offsetWidth);

View File

@@ -87,7 +87,7 @@ function CommandList({
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-clip overflow-y-auto",
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
@@ -96,13 +96,12 @@ function CommandList({
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
className="py-6 text-center text-sm"
{...props}
/>
);
@@ -116,7 +115,7 @@ function CommandGroup({
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-y-auto p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}

View File

@@ -566,7 +566,7 @@ export function ControlledDataTable<TData, TValue>({
))}
</TableHeader>
<TableBody>
{(table.getRowModel().rows ?? []).length > 0 ? (
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}

View File

@@ -1,69 +0,0 @@
import { orgQueries } from "@app/lib/queries";
import type { ListUsersResponse } from "@server/routers/user";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { useDebounce } from "use-debounce";
import { useTranslations } from "next-intl";
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
export type SelectedUser = {
id: string;
text: string;
ipdName?: string | null;
};
export type UsersSelectorProps = {
orgId: string;
selectedUsers?: SelectedUser[];
onSelectUsers: (users: SelectedUser[]) => void;
disabled?: boolean;
lockedIds?: Set<string>;
};
export function UsersSelector({
orgId,
selectedUsers = [],
onSelectUsers,
disabled,
lockedIds
}: UsersSelectorProps) {
const t = useTranslations();
const [userSearchQuery, setUserSearchQuery] = useState("");
const [debouncedValue] = useDebounce(userSearchQuery, 150);
const { data: users = [] } = useQuery(
orgQueries.users({ orgId, perPage: 10, query: debouncedValue })
);
// always include the selected users in the list (if the user isn't searching)
const usersShown = useMemo(() => {
const allUsers: Array<SelectedUser> = users.map((u) => ({
id: u.id,
text: getUserDisplayName(u)
}));
if (debouncedValue.trim().length === 0) {
for (const user of selectedUsers) {
if (!allUsers.find((u) => u.id === user.id)) {
allUsers.unshift(user);
}
}
}
return allUsers;
}, [users, selectedUsers, debouncedValue]);
return (
<MultiSelectTagInput
buttonText={t("alertingSelectUsers")}
searchQuery={userSearchQuery}
onSearch={setUserSearchQuery}
options={usersShown}
value={selectedUsers}
onChange={onSelectUsers}
disabled={disabled}
lockedIds={lockedIds}
/>
);
}

View File

@@ -8,7 +8,6 @@ type UserDisplayNameInput =
email?: string | null;
name?: string | null;
username?: string | null;
idpName?: string | null;
};
/**
@@ -22,25 +21,16 @@ export function getUserDisplayName(input: UserDisplayNameInput): string {
let email: string | null | undefined;
let name: string | null | undefined;
let username: string | null | undefined;
let idpName: string | null | undefined;
if ("user" in input) {
email = input.user.email;
name = input.user.name;
username = input.user.username;
idpName = input.user.idpName;
} else {
email = input.email;
name = input.name;
username = input.username;
idpName = input.idpName;
}
let nameShown = email || name || username || "";
if (idpName) {
nameShown = `${nameShown} (${idpName})`;
}
return nameShown;
return email || name || username || "";
}

View File

@@ -8,12 +8,8 @@ import type {
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
import type {
GetResourceWhitelistResponse,
GetResourcePoliciesResponse,
ListResourceNamesResponse,
ListResourcesResponse,
ListResourceRolesResponse,
ListResourceRulesResponse,
ListResourceUsersResponse
ListResourcesResponse
} from "@server/routers/resource";
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
import type { ListRolesResponse } from "@server/routers/role";
@@ -37,9 +33,6 @@ import { remote } from "./api";
import { durationToMs } from "./durationToMs";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
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 = {
link: string | null;
@@ -132,56 +125,24 @@ export const orgQueries = {
return res.data.data.clients;
}
}),
users: ({
orgId,
query,
perPage = 10_000
}: {
orgId: string;
query?: string;
perPage?: number;
}) =>
users: ({ orgId }: { orgId: string }) =>
queryOptions({
queryKey: ["ORG", orgId, "USERS", { query, perPage }] as const,
queryKey: ["ORG", orgId, "USERS"] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: perPage.toString()
});
if (query?.trim()) {
sp.set("query", query);
}
const res = await meta!.api.get<
AxiosResponse<ListUsersResponse>
>(`/org/${orgId}/users?${sp.toString()}`, { signal });
>(`/org/${orgId}/users`, { signal });
return res.data.data.users;
}
}),
roles: ({
orgId,
query,
perPage = 10_000
}: {
orgId: string;
query?: string;
perPage?: number;
}) =>
roles: ({ orgId }: { orgId: string }) =>
queryOptions({
queryKey: ["ORG", orgId, "ROLES", { query, perPage }] as const,
queryKey: ["ORG", orgId, "ROLES"] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: perPage.toString()
});
if (query?.trim()) {
sp.set("query", query);
}
const res = await meta!.api.get<
AxiosResponse<ListRolesResponse>
>(`/org/${orgId}/roles?${sp.toString()}`, { signal });
>(`/org/${orgId}/roles`, { signal });
return res.data.data.roles;
}
@@ -547,28 +508,6 @@ export const orgQueries = {
);
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;
}
})
};
@@ -626,7 +565,7 @@ export const resourceQueries = {
queryKey: ["RESOURCES", resourceId, "USERS"] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListResourceUsersResponse>
AxiosResponse<ListSiteResourceUsersResponse>
>(`/resource/${resourceId}/users`, { signal });
return res.data.data.users;
}
@@ -636,23 +575,12 @@ export const resourceQueries = {
queryKey: ["RESOURCES", resourceId, "ROLES"] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListResourceRolesResponse>
AxiosResponse<ListSiteResourceRolesResponse>
>(`/resource/${resourceId}/roles`, { signal });
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 }) =>
queryOptions({
queryKey: ["SITE_RESOURCES", siteResourceId, "USERS"] as const,
@@ -707,17 +635,6 @@ export const resourceQueries = {
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) =>
queryOptions({
queryKey: ["RESOURCES_NAMES", orgId] as const,

View File

@@ -1,64 +0,0 @@
"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;
}