From 6fca30f2cb6cbde86b139bf7ad88b1b8d0aad2f0 Mon Sep 17 00:00:00 2001 From: = Date: Sun, 19 Oct 2025 01:38:34 +0530 Subject: [PATCH] feat: completed base setup --- backend/src/ee/routes/v1/index.ts | 2 + backend/src/ee/routes/v1/license-router.ts | 2 +- backend/src/ee/routes/v1/sub-org-router.ts | 14 +++- .../src/ee/services/license/license-fns.ts | 2 +- .../src/ee/services/license/license-types.ts | 2 +- .../ee/services/permission/permission-dal.ts | 2 +- .../ee/services/sub-org/sub-org-service.ts | 2 +- backend/src/server/routes/v1/auth-router.ts | 1 + .../server/routes/v1/organization-router.ts | 10 ++- backend/src/services/org/org-service.ts | 18 +++-- frontend/src/config/request.ts | 6 ++ .../OrganizationContext.tsx | 9 ++- frontend/src/hooks/api/index.tsx | 1 + .../src/hooks/api/organization/queries.tsx | 4 +- .../src/hooks/api/subOrganizations/index.tsx | 7 ++ .../hooks/api/subOrganizations/mutations.tsx | 22 +++++ .../hooks/api/subOrganizations/queries.tsx | 28 +++++++ .../src/hooks/api/subOrganizations/types.ts | 17 ++++ frontend/src/hooks/api/subscriptions/types.ts | 1 + .../components/NavBar/Navbar.tsx | 29 ++++++- .../NavBar/NewSubOrganizationForm.tsx | 80 +++++++++++++++++++ frontend/src/pages/organization/layout.tsx | 11 ++- 22 files changed, 249 insertions(+), 21 deletions(-) create mode 100644 frontend/src/hooks/api/subOrganizations/index.tsx create mode 100644 frontend/src/hooks/api/subOrganizations/mutations.tsx create mode 100644 frontend/src/hooks/api/subOrganizations/queries.tsx create mode 100644 frontend/src/hooks/api/subOrganizations/types.ts create mode 100644 frontend/src/layouts/OrganizationLayout/components/NavBar/NewSubOrganizationForm.tsx diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 42392ba55..8d4671e50 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -50,10 +50,12 @@ import { registerSshHostGroupRouter } from "./ssh-host-group-router"; import { registerSshHostRouter } from "./ssh-host-router"; import { registerTrustedIpRouter } from "./trusted-ip-router"; import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router"; +import { registerSubOrgRouter } from "./sub-org-router"; export const registerV1EERoutes = async (server: FastifyZodProvider) => { // org role starts with organization await server.register(registerOrgRoleRouter, { prefix: "/organization" }); + await server.register(registerSubOrgRouter, { prefix: "/sub-organizations" }); await server.register(registerLicenseRouter, { prefix: "/organizations" }); // depreciated in favour of infisical workspace diff --git a/backend/src/ee/routes/v1/license-router.ts b/backend/src/ee/routes/v1/license-router.ts index 17923975d..8ab31dbe4 100644 --- a/backend/src/ee/routes/v1/license-router.ts +++ b/backend/src/ee/routes/v1/license-router.ts @@ -58,7 +58,7 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const plan = await server.services.license.getOrgPlan({ actorId: req.permission.id, actor: req.permission.type, - actorOrgId: req.permission.orgId, + actorOrgId: req.permission.parentOrgId, actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId, refreshCache: req.query.refreshCache diff --git a/backend/src/ee/routes/v1/sub-org-router.ts b/backend/src/ee/routes/v1/sub-org-router.ts index 9b9ecfea2..281b54e35 100644 --- a/backend/src/ee/routes/v1/sub-org-router.ts +++ b/backend/src/ee/routes/v1/sub-org-router.ts @@ -7,6 +7,14 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +const sanitiziedSubOrganizationSchema = OrganizationsSchema.pick({ + id: true, + name: true, + slug: true, + createdAt: true, + updatedAt: true +}); + export const registerSubOrgRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -28,7 +36,7 @@ export const registerSubOrgRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - organization: OrganizationsSchema + organization: sanitiziedSubOrganizationSchema }) } }, @@ -77,7 +85,7 @@ export const registerSubOrgRouter = async (server: FastifyZodProvider) => { } ], querystring: z.object({ - limit: z.coerce.number().min(1).max(100).default(25).describe(SUB_ORGANIZATIONS.LIST.limit), + limit: z.coerce.number().min(1).max(1000).default(25).describe(SUB_ORGANIZATIONS.LIST.limit), offset: z.coerce.number().min(0).default(0).describe(SUB_ORGANIZATIONS.LIST.offset), isAccessible: z .enum(["true", "false"]) @@ -87,7 +95,7 @@ export const registerSubOrgRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - organizations: OrganizationsSchema.array() + organizations: sanitiziedSubOrganizationSchema.array() }) } }, diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index e5c775f07..8d0d9d74b 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -28,7 +28,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ rbac: false, githubOrgSync: false, customRateLimits: false, - childOrganization: true, + subOrganization: true, customAlerts: false, secretAccessInsights: false, auditLogs: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 8090d789e..88c1edb2e 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -33,7 +33,7 @@ export type TFeatureSet = { membersUsed: number; identityLimit: null; identitiesUsed: number; - childOrganization: true; + subOrganization: true; environmentLimit: null; environmentsUsed: 0; secretVersioning: true; diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index eb45158ab..d35abe665 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -285,7 +285,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { MembershipsSchema.extend({ orgAuthEnforced: z.boolean().optional().nullable(), shouldUseNewPrivilegeSystem: z.boolean().optional().nullable(), - parentOrgId: z.boolean().optional().nullable(), + parentOrgId: z.string().optional().nullable(), orgGoogleSsoAuthEnforced: z.boolean(), bypassOrgAuthEnabled: z.boolean() }).parse(el), diff --git a/backend/src/ee/services/sub-org/sub-org-service.ts b/backend/src/ee/services/sub-org/sub-org-service.ts index 37c5545c0..86dd03abd 100644 --- a/backend/src/ee/services/sub-org/sub-org-service.ts +++ b/backend/src/ee/services/sub-org/sub-org-service.ts @@ -45,7 +45,7 @@ export const subOrgServiceFactory = ({ ); const orgLicensePlan = await licenseService.getPlan(permissionActor.parentOrgId); - if (!orgLicensePlan.gateway) { + if (!orgLicensePlan.subOrganization) { throw new BadRequestError({ message: "Child organization creation failed. Please upgrade your instance to Infisical's Enterprise plan." }); diff --git a/backend/src/server/routes/v1/auth-router.ts b/backend/src/server/routes/v1/auth-router.ts index 911979b60..48939844e 100644 --- a/backend/src/server/routes/v1/auth-router.ts +++ b/backend/src/server/routes/v1/auth-router.ts @@ -94,6 +94,7 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { decodedToken.userId, decodedToken.organizationId, decodedToken.authMethod, + decodedToken.organizationId, decodedToken.organizationId ); if (org && org.userTokenExpiration) { diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 872b7b157..b640fba1a 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -59,7 +59,14 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - organization: sanitizedOrganizationSchema + organization: sanitizedOrganizationSchema.extend({ + subOrganization: z + .object({ + id: z.string(), + name: z.string() + }) + .optional() + }) }) } }, @@ -69,6 +76,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { req.permission.id, req.params.organizationId, req.permission.authMethod, + req.permission.parentOrgId, req.permission.orgId ); return { organization }; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index b053349d3..011af3520 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -157,23 +157,31 @@ export const orgServiceFactory = ({ userId: string, orgId: string, actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined + parentOrgId: string, + actorOrgId: string ) => { await permissionService.getOrgPermission({ actor: ActorType.USER, actorId: userId, orgId, actorAuthMethod, - actorOrgId, + actorOrgId: parentOrgId, scope: OrganizationActionScope.Any }); const appCfg = getConfig(); const org = await orgDAL.findOrgById(orgId); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); - if (!org.userTokenExpiration) { - return { ...org, userTokenExpiration: appCfg.JWT_REFRESH_LIFETIME }; + + const hasSubOrg = actorOrgId !== parentOrgId; + let subOrg; + if (hasSubOrg) { + subOrg = await orgDAL.findOne({ parentOrgId, id: actorOrgId }); } - return org; + + if (!org.userTokenExpiration) { + return { ...org, userTokenExpiration: appCfg.JWT_REFRESH_LIFETIME, subOrganization: subOrg }; + } + return { ...org, subOrganization: subOrg }; }; /* * Get all organization a user part of diff --git a/frontend/src/config/request.ts b/frontend/src/config/request.ts index a37b38cb6..3fc01ccac 100644 --- a/frontend/src/config/request.ts +++ b/frontend/src/config/request.ts @@ -24,6 +24,8 @@ apiRequest.interceptors.request.use((config) => { const token = getAuthToken(); const providerAuthToken = SecurityClient.getProviderAuthToken(); + const params = new URLSearchParams(window.location.search); + if (config.headers) { if (signupTempToken) { // eslint-disable-next-line no-param-reassign @@ -38,6 +40,10 @@ apiRequest.interceptors.request.use((config) => { // eslint-disable-next-line no-param-reassign config.headers.Authorization = `Bearer ${providerAuthToken}`; } + const subOrganization = params.get("subOrganization"); + if (subOrganization) { + config.headers.set("x-infisical-org", subOrganization); + } } return config; diff --git a/frontend/src/context/OrganizationContext/OrganizationContext.tsx b/frontend/src/context/OrganizationContext/OrganizationContext.tsx index 26865b5bf..b7726e6a4 100644 --- a/frontend/src/context/OrganizationContext/OrganizationContext.tsx +++ b/frontend/src/context/OrganizationContext/OrganizationContext.tsx @@ -15,5 +15,12 @@ export const useOrganization = () => { staleTime: Infinity }); - return { currentOrg }; + return { + currentOrg: { + ...currentOrg, + id: currentOrg?.subOrganization?.id || currentOrg?.id, + parentOrgId: currentOrg.id + }, + isSubOrganization: Boolean(currentOrg.subOrganization) + }; }; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index d65c7e72c..6167f6e07 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -48,6 +48,7 @@ export * from "./sshCertificateTemplates"; export * from "./sshHost"; export * from "./sshHostGroup"; export * from "./ssoConfig"; +export * from "./subOrganizations"; export * from "./subscriptions"; export * from "./tags"; export * from "./trustedIps"; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 15e1b861c..ce3071584 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -64,7 +64,9 @@ export const useGetOrganizations = () => { export const fetchOrganizationById = async (id: string) => { const { data: { organization } - } = await apiRequest.get<{ organization: Organization }>(`/api/v1/organization/${id}`); + } = await apiRequest.get<{ + organization: Organization & { subOrganization?: { id: string; name: string } }; + }>(`/api/v1/organization/${id}`); return organization; }; diff --git a/frontend/src/hooks/api/subOrganizations/index.tsx b/frontend/src/hooks/api/subOrganizations/index.tsx new file mode 100644 index 000000000..85095fcc6 --- /dev/null +++ b/frontend/src/hooks/api/subOrganizations/index.tsx @@ -0,0 +1,7 @@ +export { useCreateSubOrganization } from "./mutations"; +export { subOrganizationsQuery } from "./queries"; +export type { + TCreateSubOrganizationDTO, + TListSubOrganizationsDTO, + TSubOrganization +} from "./types"; diff --git a/frontend/src/hooks/api/subOrganizations/mutations.tsx b/frontend/src/hooks/api/subOrganizations/mutations.tsx new file mode 100644 index 000000000..828aea6f4 --- /dev/null +++ b/frontend/src/hooks/api/subOrganizations/mutations.tsx @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { subOrganizationsQuery } from "./queries"; +import { TCreateSubOrganizationDTO, TSubOrganization } from "./types"; + +export const useCreateSubOrganization = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (dto: TCreateSubOrganizationDTO) => { + const { data } = await apiRequest.post<{ organization: TSubOrganization }>( + "/api/v1/sub-organizations", + dto + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: subOrganizationsQuery.allKey() }); + } + }); +}; diff --git a/frontend/src/hooks/api/subOrganizations/queries.tsx b/frontend/src/hooks/api/subOrganizations/queries.tsx new file mode 100644 index 000000000..99ccb7770 --- /dev/null +++ b/frontend/src/hooks/api/subOrganizations/queries.tsx @@ -0,0 +1,28 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TListSubOrganizationsDTO, TSubOrganization } from "./types"; + +export const subOrganizationsQuery = { + allKey: () => ["sub-organizations"] as const, + listKey: (params?: TListSubOrganizationsDTO) => + [...subOrganizationsQuery.allKey(), "list", params] as const, + list: (params: TListSubOrganizationsDTO) => + queryOptions({ + queryKey: subOrganizationsQuery.listKey(params), + queryFn: async () => { + const { data } = await apiRequest.get<{ organizations: TSubOrganization[] }>( + "/api/v1/sub-organizations", + { + params: { + limit: params.limit, + offset: params.offset, + isAccessible: params.isAccessible + } + } + ); + return data.organizations; + } + }) +}; diff --git a/frontend/src/hooks/api/subOrganizations/types.ts b/frontend/src/hooks/api/subOrganizations/types.ts new file mode 100644 index 000000000..e6fa39e1e --- /dev/null +++ b/frontend/src/hooks/api/subOrganizations/types.ts @@ -0,0 +1,17 @@ +export type TSubOrganization = { + id: string; + name: string; + slug: string; + createdAt: string; + updatedAt: string; +}; + +export type TCreateSubOrganizationDTO = { + name: string; +}; + +export type TListSubOrganizationsDTO = { + limit?: number; + offset?: number; + isAccessible?: boolean; +}; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index ede2f8cf1..2a1e078fc 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -13,6 +13,7 @@ export type SubscriptionPlan = { customRateLimits: boolean; pitRecovery: boolean; githubOrgSync: boolean; + subOrganization?: boolean; ipAllowlisting: boolean; rbac: boolean; secretVersioning: boolean; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index f3182ab83..ffaf5b068 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -6,12 +6,14 @@ import { faBook, faCaretDown, faCheck, + faCubes, faEnvelope, faExclamationTriangle, faGlobe, faInfinity, faInfo, faInfoCircle, + faPlus, faServer, faSignOut, faToolbox, @@ -19,7 +21,7 @@ import { faUsers } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useLocation, useNavigate, useRouter, useRouterState } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; @@ -34,6 +36,9 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + DropdownSubMenu, + DropdownSubMenuContent, + DropdownSubMenuTrigger, IconButton, Modal, ModalContent, @@ -44,7 +49,7 @@ import { envConfig } from "@app/config/env"; import { useOrganization, useSubscription, useUser } from "@app/context"; import { isInfisicalCloud } from "@app/helpers/platform"; import { useToggle } from "@app/hooks"; -import { projectKeys, useGetOrganizations, useGetOrgTrialUrl, useLogoutUser } from "@app/hooks/api"; +import { projectKeys, subOrganizationsQuery, useGetOrganizations, useGetOrgTrialUrl, useLogoutUser } from "@app/hooks/api"; import { authKeys, selectOrganization } from "@app/hooks/api/auth/queries"; import { MfaMethod } from "@app/hooks/api/auth/types"; import { getAuthToken } from "@app/hooks/api/reactQuery"; @@ -54,6 +59,7 @@ import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils"; import { ServerAdminsPanel } from "../ServerAdminsPanel/ServerAdminsPanel"; import { NotificationDropdown } from "./NotificationDropdown"; +import { NewSubOrganizationForm } from "./NewSubOrganizationForm"; const getPlan = (subscription: SubscriptionPlan) => { if (subscription.groups) return "Enterprise"; @@ -119,9 +125,15 @@ export const INFISICAL_SUPPORT_OPTIONS = [ export const Navbar = () => { const { user } = useUser(); const { subscription } = useSubscription(); - const { currentOrg } = useOrganization(); + const { currentOrg, isSubOrganization } = useOrganization(); + const [showAdminsModal, setShowAdminsModal] = useState(false); + const [showSubOrgForm, setShowSubOrgForm] = useState(false); const [showCardDeclinedModal, setShowCardDeclinedModal] = useState(false); + const { data: subOrganizations = [] } = useQuery({ + ...subOrganizationsQuery.list({ limit: 500 }), + enabled: Boolean(subscription.subOrganization) && !isSubOrganization + }); useEffect(() => { if (subscription?.cardDeclined && !sessionStorage.getItem("paymentFailed")) { @@ -517,6 +529,7 @@ export const Navbar = () => { + { + + +
+ setShowSubOrgForm(true)} /> +
+
+
diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/NewSubOrganizationForm.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/NewSubOrganizationForm.tsx new file mode 100644 index 000000000..81946de8d --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/NewSubOrganizationForm.tsx @@ -0,0 +1,80 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { GenericResourceNameSchema } from "@app/lib/schemas"; +import { useCreateSubOrganization } from "@app/hooks/api"; + +type ContentProps = { + onClose: () => void; +}; + +const AddOrgSchema = z.object({ + name: GenericResourceNameSchema.nonempty("Suborganization name required") +}); + +type FormData = z.infer; + +export const NewSubOrganizationForm = ({ onClose }: ContentProps) => { + const createSubOrg = useCreateSubOrganization(); + + const { + handleSubmit, + control, + formState: { isSubmitting } + } = useForm({ + defaultValues: { + name: "", + invitees: [] + }, + resolver: zodResolver(AddOrgSchema) + }); + + const onSubmit = async ({ name }: FormData) => { + try { + await createSubOrg.mutateAsync({ + name + }); + + createNotification({ + type: "success", + text: "Successfully created sub organization" + }); + onClose(); + } catch { + createNotification({ + text: "Failed to create sub organization", + type: "error" + }); + } + }; + + return ( +
+ ( + + + + )} + control={control} + name="name" + /> +
+ + +
+ + ); +}; diff --git a/frontend/src/pages/organization/layout.tsx b/frontend/src/pages/organization/layout.tsx index 79bdbf216..233b490b3 100644 --- a/frontend/src/pages/organization/layout.tsx +++ b/frontend/src/pages/organization/layout.tsx @@ -1,7 +1,14 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, retainSearchParams } from "@tanstack/react-router"; import { OrganizationLayout } from "@app/layouts/OrganizationLayout"; +import { z } from "zod"; export const Route = createFileRoute("/_authenticate/_inject-org-details/_org-layout")({ - component: OrganizationLayout + component: OrganizationLayout, + validateSearch: z.object({ + subOrganization: z.string().optional() + }), + search: { + middlewares: [retainSearchParams(["subOrganization"])] + } });