diff --git a/backend/src/db/migrations/20250515164622_select-org-products.ts b/backend/src/db/migrations/20250515164622_select-org-products.ts new file mode 100644 index 000000000..c290a4ee2 --- /dev/null +++ b/backend/src/db/migrations/20250515164622_select-org-products.ts @@ -0,0 +1,53 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const columns = await knex.table(TableName.Organization).columnInfo(); + + await knex.schema.alterTable(TableName.Organization, (t) => { + if (!columns.secretsProductEnabled) { + t.boolean("secretsProductEnabled").defaultTo(true); + } + if (!columns.pkiProductEnabled) { + t.boolean("pkiProductEnabled").defaultTo(true); + } + if (!columns.kmsProductEnabled) { + t.boolean("kmsProductEnabled").defaultTo(true); + } + if (!columns.sshProductEnabled) { + t.boolean("sshProductEnabled").defaultTo(true); + } + if (!columns.scannerProductEnabled) { + t.boolean("scannerProductEnabled").defaultTo(true); + } + if (!columns.shareSecretsProductEnabled) { + t.boolean("shareSecretsProductEnabled").defaultTo(true); + } + }); +} + +export async function down(knex: Knex): Promise { + const columns = await knex.table(TableName.Organization).columnInfo(); + + await knex.schema.alterTable(TableName.Organization, (t) => { + if (columns.secretsProductEnabled) { + t.dropColumn("secretsProductEnabled"); + } + if (columns.pkiProductEnabled) { + t.dropColumn("pkiProductEnabled"); + } + if (columns.kmsProductEnabled) { + t.dropColumn("kmsProductEnabled"); + } + if (columns.sshProductEnabled) { + t.dropColumn("sshProductEnabled"); + } + if (columns.scannerProductEnabled) { + t.dropColumn("scannerProductEnabled"); + } + if (columns.shareSecretsProductEnabled) { + t.dropColumn("shareSecretsProductEnabled"); + } + }); +} diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index 8d8279802..6779d5407 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -28,7 +28,13 @@ export const OrganizationsSchema = z.object({ privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), privilegeUpgradeInitiatedAt: z.date().nullable().optional(), bypassOrgAuthEnabled: z.boolean().default(false), - userTokenExpiration: z.string().nullable().optional() + userTokenExpiration: z.string().nullable().optional(), + secretsProductEnabled: z.boolean().default(true).nullable().optional(), + pkiProductEnabled: z.boolean().default(true).nullable().optional(), + kmsProductEnabled: z.boolean().default(true).nullable().optional(), + sshProductEnabled: z.boolean().default(true).nullable().optional(), + scannerProductEnabled: z.boolean().default(true).nullable().optional(), + shareSecretsProductEnabled: z.boolean().default(true).nullable().optional() }); export type TOrganizations = z.infer; diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index da1a251ff..e14dacebb 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -275,7 +275,13 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }, { message: "Duration value must be at least 1" } ) - .optional() + .optional(), + secretsProductEnabled: z.boolean().optional(), + pkiProductEnabled: z.boolean().optional(), + kmsProductEnabled: z.boolean().optional(), + sshProductEnabled: z.boolean().optional(), + scannerProductEnabled: z.boolean().optional(), + shareSecretsProductEnabled: z.boolean().optional() }), response: { 200: z.object({ diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index 5a1a4c333..39a1680a9 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -18,5 +18,11 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ privilegeUpgradeInitiatedByUsername: true, privilegeUpgradeInitiatedAt: true, bypassOrgAuthEnabled: true, - userTokenExpiration: true + userTokenExpiration: true, + secretsProductEnabled: true, + pkiProductEnabled: true, + kmsProductEnabled: true, + sshProductEnabled: true, + scannerProductEnabled: true, + shareSecretsProductEnabled: true }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index d794391c1..bcbd9e0e5 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -355,7 +355,13 @@ export const orgServiceFactory = ({ selectedMfaMethod, allowSecretSharingOutsideOrganization, bypassOrgAuthEnabled, - userTokenExpiration + userTokenExpiration, + secretsProductEnabled, + pkiProductEnabled, + kmsProductEnabled, + sshProductEnabled, + scannerProductEnabled, + shareSecretsProductEnabled } }: TUpdateOrgDTO) => { const appCfg = getConfig(); @@ -457,7 +463,13 @@ export const orgServiceFactory = ({ selectedMfaMethod, allowSecretSharingOutsideOrganization, bypassOrgAuthEnabled, - userTokenExpiration + userTokenExpiration, + secretsProductEnabled, + pkiProductEnabled, + kmsProductEnabled, + sshProductEnabled, + scannerProductEnabled, + shareSecretsProductEnabled }); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); return org; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 702cd25bf..9625934fb 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -75,6 +75,12 @@ export type TUpdateOrgDTO = { allowSecretSharingOutsideOrganization: boolean; bypassOrgAuthEnabled: boolean; userTokenExpiration: string; + secretsProductEnabled: boolean; + pkiProductEnabled: boolean; + kmsProductEnabled: boolean; + sshProductEnabled: boolean; + scannerProductEnabled: boolean; + shareSecretsProductEnabled: boolean; }>; } & TOrgPermission; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 06125b1d7..947353162 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -112,7 +112,13 @@ export const useUpdateOrg = () => { selectedMfaMethod, allowSecretSharingOutsideOrganization, bypassOrgAuthEnabled, - userTokenExpiration + userTokenExpiration, + secretsProductEnabled, + pkiProductEnabled, + kmsProductEnabled, + sshProductEnabled, + scannerProductEnabled, + shareSecretsProductEnabled }) => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, @@ -124,7 +130,13 @@ export const useUpdateOrg = () => { selectedMfaMethod, allowSecretSharingOutsideOrganization, bypassOrgAuthEnabled, - userTokenExpiration + userTokenExpiration, + secretsProductEnabled, + pkiProductEnabled, + kmsProductEnabled, + sshProductEnabled, + scannerProductEnabled, + shareSecretsProductEnabled }); }, onSuccess: () => { diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index 6f63d003e..ab015f890 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -20,6 +20,12 @@ export type Organization = { allowSecretSharingOutsideOrganization?: boolean; userTokenExpiration?: string; userRole: string; + secretsProductEnabled: boolean; + pkiProductEnabled: boolean; + kmsProductEnabled: boolean; + sshProductEnabled: boolean; + scannerProductEnabled: boolean; + shareSecretsProductEnabled: boolean; }; export type UpdateOrgDTO = { @@ -34,6 +40,12 @@ export type UpdateOrgDTO = { allowSecretSharingOutsideOrganization?: boolean; bypassOrgAuthEnabled?: boolean; userTokenExpiration?: string; + secretsProductEnabled?: boolean; + pkiProductEnabled?: boolean; + kmsProductEnabled?: boolean; + sshProductEnabled?: boolean; + scannerProductEnabled?: boolean; + shareSecretsProductEnabled?: boolean; }; export type BillingDetails = { diff --git a/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx b/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx index b85499073..61863e79e 100644 --- a/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx @@ -268,77 +268,91 @@ export const MinimizedOrgSidebar = () => {
- - {({ isActive }) => ( - - Secrets - - )} - - - {({ isActive }) => ( - - PKI - - )} - - - {({ isActive }) => ( - - KMS - - )} - - - {({ isActive }) => ( - - SSH - - )} - -
- - {({ isActive }) => ( - - Scanner - - )} - - - {({ isActive }) => ( - - Share - - )} - + {currentOrg.secretsProductEnabled && ( + + {({ isActive }) => ( + + Secrets + + )} + + )} + {currentOrg.pkiProductEnabled && ( + + {({ isActive }) => ( + + PKI + + )} + + )} + {currentOrg.kmsProductEnabled && ( + + {({ isActive }) => ( + + KMS + + )} + + )} + {currentOrg.sshProductEnabled && ( + + {({ isActive }) => ( + + SSH + + )} + + )} + {(currentOrg.scannerProductEnabled || currentOrg.shareSecretsProductEnabled) && ( +
+ )} + {currentOrg.scannerProductEnabled && ( + + {({ isActive }) => ( + + Scanner + + )} + + )} + {currentOrg.shareSecretsProductEnabled && ( + + {({ isActive }) => ( + + Share + + )} + + )}
{ const { membership } = useOrgPermission(); return ( -
- - - {membership && membership.role === "admin" && } -
+ <> +
+ + + {membership && membership.role === "admin" && } +
+ {membership && membership.role === "admin" && } + ); }; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx new file mode 100644 index 000000000..b9991d0e5 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from "react"; + +import { Switch } from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useUpdateOrg } from "@app/hooks/api"; +import axios from "axios"; +import { createNotification } from "@app/components/notifications"; + +export const OrgProductSelectSection = () => { + const [toggledProducts, setToggledProducts] = useState<{ + [key: string]: { name: string; enabled: boolean }; + }>({ + secretsProductEnabled: { + name: "Secret Management", + enabled: true + }, + pkiProductEnabled: { + name: "Certificate Management", + enabled: true + }, + kmsProductEnabled: { + name: "KMS", + enabled: true + }, + sshProductEnabled: { + name: "SSH", + enabled: true + }, + scannerProductEnabled: { + name: "Scanner", + enabled: true + }, + shareSecretsProductEnabled: { + name: "Share Secrets", + enabled: true + } + }); + + const [isLoading, setIsLoading] = useState(false); + + const { currentOrg } = useOrganization(); + const { mutateAsync } = useUpdateOrg(); + + useEffect(() => { + Object.entries(currentOrg).forEach(([key, value]) => { + if (key in toggledProducts && typeof value === "boolean") { + setToggledProducts((products) => ({ + ...products, + [key]: { ...products[key], enabled: value } + })); + } + }); + }, [currentOrg]); + + const onProductToggle = async (value: boolean, key: string) => { + setIsLoading(true); + + setToggledProducts((products) => ({ + ...products, + [key]: { ...products[key], enabled: value } + })); + + try { + await mutateAsync({ + orgId: currentOrg.id, + [key]: value + }); + } catch (e) { + if (axios.isAxiosError(e)) { + const { message = "Something went wrong" } = e.response?.data as { message: string }; + createNotification({ + type: "error", + text: message + }); + } + } + + setIsLoading(false); + }; + + return ( +
+

Organization Products

+

+ Select which products are available for your organization. +

+ +
+ {Object.entries(toggledProducts).map(([key, product]) => ( + onProductToggle(value, key)} + isChecked={product.enabled} + className="ml-0" + containerClassName="flex-row-reverse gap-3 w-fit" + > + {product.name} + + ))} +
+
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/index.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/index.tsx new file mode 100644 index 000000000..fb1bdbb29 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/index.tsx @@ -0,0 +1 @@ +export { OrgProductSelectSection } from "./OrgProductSelectSection";