Merge pull request #4701 from Infisical/feat/sub-org

feat: sub organization
This commit is contained in:
Scott Wilson
2025-10-21 14:18:48 -07:00
committed by GitHub
200 changed files with 5621 additions and 2275 deletions
@@ -6,12 +6,15 @@ import {
faBook,
faCaretDown,
faCheck,
faChevronRight,
faCubes,
faEnvelope,
faExclamationTriangle,
faGlobe,
faInfinity,
faInfo,
faInfoCircle,
faPlus,
faServer,
faSignOut,
faToolbox,
@@ -19,7 +22,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 +37,9 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownSubMenu,
DropdownSubMenuContent,
DropdownSubMenuTrigger,
IconButton,
Modal,
ModalContent,
@@ -44,15 +50,22 @@ 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";
import { SubscriptionPlan } from "@app/hooks/api/types";
import { Organization, SubscriptionPlan } from "@app/hooks/api/types";
import { AuthMethod } from "@app/hooks/api/users/types";
import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils";
import { ServerAdminsPanel } from "../ServerAdminsPanel/ServerAdminsPanel";
import { NewSubOrganizationForm } from "./NewSubOrganizationForm";
import { NotificationDropdown } from "./NotificationDropdown";
const getPlan = (subscription: SubscriptionPlan) => {
@@ -119,10 +132,18 @@ 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 subOrgQuery = subOrganizationsQuery.list({ limit: 500, isAccessible: true });
const { data: subOrganizations = [] } = useQuery({
...subOrgQuery,
enabled: Boolean(subscription.subOrganization)
});
useEffect(() => {
if (subscription?.cardDeclined && !sessionStorage.getItem("paymentFailed")) {
sessionStorage.setItem("paymentFailed", "true");
@@ -137,6 +158,7 @@ export const Navbar = () => {
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const router = useRouter();
const queryClient = useQueryClient();
const [isOrgSelectOpen, setIsOrgSelectOpen] = useState(false);
const location = useLocation();
const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context });
@@ -161,6 +183,7 @@ export const Navbar = () => {
}
await router.invalidate();
await navigateUserToOrg(navigate, orgId);
queryClient.removeQueries({ queryKey: subOrgQuery.queryKey });
};
const { mutateAsync } = useGetOrgTrialUrl();
@@ -206,6 +229,33 @@ export const Navbar = () => {
const isOrgScope = location.pathname.startsWith("/organization"); // TODO: scott/akhil is this adequate?
const handleOrgNav = async (org: Organization) => {
if (currentOrg?.id === org.id) return;
if (org.authEnforced) {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
await logout.mutateAsync();
if (org.orgAuthMethod === AuthMethod.OIDC) {
window.open(`/api/v1/sso/oidc/login?orgSlug=${org.slug}`);
} else {
window.open(`/api/v1/sso/redirect/saml2/organizations/${org.slug}`);
}
window.close();
return;
}
if (org.googleSsoAuthEnforced) {
await logout.mutateAsync();
window.open(`/api/v1/sso/redirect/google?org_slug=${org.slug}`);
window.close();
return;
}
handleOrgChange(org?.id);
};
return (
<div className="z-10 flex min-h-12 items-center bg-mineshaft-900 px-4 pt-1">
<div className="mr-auto flex items-center overflow-hidden">
@@ -235,38 +285,45 @@ export const Navbar = () => {
) : (
<>
<div className="flex items-center overflow-hidden">
<DropdownMenu modal={false}>
<Link className="overflow-hidden" to="/organization/projects">
<div className="group flex cursor-pointer items-center gap-2 overflow-hidden text-sm text-white transition-all duration-100 hover:text-primary">
<Badge
variant="org"
className={twMerge(
"max-w-full min-w-0 cursor-pointer text-sm",
!isOrgScope &&
"bg-transparent text-mineshaft-200 hover:bg-transparent hover:underline"
)}
>
<FontAwesomeIcon icon={faGlobe} />
<p className="truncate">{currentOrg?.name}</p>
</Badge>
<div className="mr-1 rounded-sm border border-mineshaft-500 px-1 text-xs text-bunker-300 no-underline!">
{getPlan(subscription)}
</div>
{subscription.cardDeclined && (
<Tooltip
content={`Your payment could not be processed${subscription.cardDeclinedReason ? `: ${subscription.cardDeclinedReason}` : ""}. Please update your payment method to continue enjoying premium features.`}
className="max-w-xs"
>
<div className="flex items-center">
<FontAwesomeIcon
icon={faExclamationTriangle}
className="animate-pulse cursor-help text-xs text-primary-400"
/>
</div>
</Tooltip>
<DropdownMenu modal={false} open={isOrgSelectOpen} onOpenChange={setIsOrgSelectOpen}>
<div className="group flex cursor-pointer items-center gap-2 overflow-hidden text-sm text-white transition-all duration-100 hover:text-primary">
<Badge
onClick={async () => {
navigate({
to: "/organization/projects",
search: (search) => ({ ...search, subOrganization: undefined })
});
if (isSubOrganization) {
await router.invalidate({ sync: true }).catch(() => null);
}
}}
variant="org"
className={twMerge(
"max-w-full min-w-0 cursor-pointer text-sm",
(!isOrgScope || isSubOrganization) &&
"bg-transparent text-mineshaft-200 hover:bg-transparent hover:underline"
)}
>
<FontAwesomeIcon icon={faGlobe} />
<p className="truncate">{currentOrg?.name}</p>
</Badge>
<div className="mr-1 rounded-sm border border-mineshaft-500 px-1 text-xs text-bunker-300 no-underline!">
{getPlan(subscription)}
</div>
</Link>
{subscription.cardDeclined && (
<Tooltip
content={`Your payment could not be processed${subscription.cardDeclinedReason ? `: ${subscription.cardDeclinedReason}` : ""}. Please update your payment method to continue enjoying premium features.`}
className="max-w-xs"
>
<div className="flex items-center">
<FontAwesomeIcon
icon={faExclamationTriangle}
className="animate-pulse cursor-help text-xs text-primary-400"
/>
</div>
</Tooltip>
)}
</div>
<DropdownMenuTrigger asChild>
<div>
<IconButton
@@ -280,58 +337,95 @@ export const Navbar = () => {
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
align="center"
side="bottom"
className="mt-6 cursor-default p-1 shadow-mineshaft-600 drop-shadow-md"
style={{ minWidth: "220px" }}
>
<div className="px-2 py-1 text-xs text-mineshaft-400 capitalize">
organizations
Organizations
</div>
{orgs?.map((org) => {
if (
subscription.subOrganization &&
(org.id === currentOrg?.id || org.id === currentOrg?.parentOrgId)
) {
return (
<DropdownSubMenu key={`${org.id}-sub-orgs`}>
<DropdownSubMenuTrigger
onClick={() => {
setIsOrgSelectOpen(false);
handleOrgNav(org);
}}
className="cursor-pointer font-normal"
>
<div className="flex w-full max-w-48 cursor-pointer items-center gap-x-2">
{currentOrg?.id === org.id && (
<FontAwesomeIcon icon={faCheck} className="shrink-0 text-primary" />
)}
<p className="truncate">{org.name}</p>
<FontAwesomeIcon className="ml-auto shrink-0" icon={faChevronRight} />
</div>
</DropdownSubMenuTrigger>
<DropdownSubMenuContent
sideOffset={8}
alignOffset={-24}
className="mt-6 cursor-default p-1 shadow-mineshaft-600 drop-shadow-md"
style={{ minWidth: "220px" }}
>
<div className="px-2 py-1 text-xs text-mineshaft-400 capitalize">
Sub-Organizations
</div>
{subOrganizations.map((subOrg) => (
<DropdownMenuItem
onClick={async () => {
navigate({
to: "/organization/projects",
search: (prev) => ({ ...prev, subOrganization: subOrg.name })
});
await router.invalidate({ sync: true }).catch(() => null);
}}
className="cursor-pointer font-normal"
key={subOrg.id}
>
<div className="flex w-full max-w-48 cursor-pointer items-center gap-x-2">
{currentOrg?.id === subOrg.id && (
<FontAwesomeIcon
icon={faCheck}
className="shrink-0 text-primary"
/>
)}
<p className="truncate">{subOrg.name}</p>
</div>
</DropdownMenuItem>
))}
{Boolean(subOrganizations.length) && (
<div className="mt-1 h-1 border-t border-mineshaft-600" />
)}
<DropdownMenuItem
className="cursor-pointer"
icon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => setShowSubOrgForm(true)}
>
New Sub-Organization
</DropdownMenuItem>
</DropdownSubMenuContent>
</DropdownSubMenu>
);
}
return (
<DropdownMenuItem key={org.id}>
<Button
onClick={async () => {
if (currentOrg?.id === org.id) return;
if (org.authEnforced) {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
await logout.mutateAsync();
if (org.orgAuthMethod === AuthMethod.OIDC) {
window.open(`/api/v1/sso/oidc/login?orgSlug=${org.slug}`);
} else {
window.open(`/api/v1/sso/redirect/saml2/organizations/${org.slug}`);
}
window.close();
return;
}
if (org.googleSsoAuthEnforced) {
await logout.mutateAsync();
window.open(`/api/v1/sso/redirect/google?org_slug=${org.slug}`);
window.close();
return;
}
handleOrgChange(org?.id);
}}
variant="plain"
colorSchema="secondary"
size="xs"
className="flex w-full items-center justify-start p-0 font-normal"
leftIcon={
currentOrg?.id === org.id && (
<FontAwesomeIcon icon={faCheck} className="mr-3 text-primary" />
)
}
>
<div className="flex w-full max-w-[150px] items-center justify-between truncate">
{org.name}
</div>
</Button>
<DropdownMenuItem
onClick={() => handleOrgNav(org)}
className="cursor-pointer font-normal"
key={org.id}
>
<div className="flex w-full max-w-48 cursor-pointer items-center gap-x-2">
{currentOrg?.id === org.id && (
<FontAwesomeIcon icon={faCheck} className="shrink-0 text-primary" />
)}
<p className="truncate">{org.name}</p>
</div>
</DropdownMenuItem>
);
})}
@@ -345,6 +439,78 @@ export const Navbar = () => {
</DropdownMenuContent>
</DropdownMenu>
</div>
{currentOrg.subOrganization && (
<>
<p className="pr-3 pl-1 text-lg text-mineshaft-400/70">/</p>
<DropdownMenu modal={false}>
<Link className="overflow-hidden" to="/organization/projects">
<Badge
variant="namespace"
className={twMerge(
"flex max-w-full min-w-0 cursor-pointer text-sm",
!isOrgScope &&
"bg-transparent text-mineshaft-200 hover:bg-transparent hover:underline"
)}
>
<FontAwesomeIcon icon={faCubes} />
<p className="truncate">{currentOrg.subOrganization.name}</p>
</Badge>
</Link>
<DropdownMenuTrigger asChild>
<div>
<IconButton
variant="plain"
colorSchema="secondary"
ariaLabel="switch-org"
className="px-2 py-1"
>
<FontAwesomeIcon icon={faCaretDown} className="text-xs text-bunker-300" />
</IconButton>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
side="bottom"
className="mt-6 cursor-default p-1 shadow-mineshaft-600 drop-shadow-md"
style={{ minWidth: "220px" }}
>
<div className="px-2 py-1 text-xs text-mineshaft-400 capitalize">
Sub-Organizations
</div>
{subOrganizations.map((subOrg) => (
<DropdownMenuItem
onClick={async () => {
navigate({
to: "/organization/projects",
search: (prev) => ({ ...prev, subOrganization: subOrg.name })
});
await router.invalidate({ sync: true }).catch(() => null);
}}
className="cursor-pointer font-normal"
key={subOrg.id}
>
<div className="flex w-full max-w-48 cursor-pointer items-center gap-x-2">
{currentOrg?.id === subOrg.id && (
<FontAwesomeIcon icon={faCheck} className="shrink-0 text-primary" />
)}
<p className="truncate">{subOrg.name}</p>
</div>
</DropdownMenuItem>
))}
{Boolean(subOrganizations.length) && (
<div className="mt-1 h-1 border-t border-mineshaft-600" />
)}
<DropdownMenuItem
className="cursor-pointer"
icon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => setShowSubOrgForm(true)}
>
New Sub-Organization
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
{!isOrgScope && (
<>
<p className="pr-3 pl-1 text-lg text-mineshaft-400/70">/</p>
@@ -517,6 +683,7 @@ export const Navbar = () => {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Modal isOpen={showCardDeclinedModal} onOpenChange={setShowCardDeclinedModal}>
<ModalContent
title={
@@ -560,6 +727,20 @@ export const Navbar = () => {
</div>
</ModalContent>
</Modal>
<Modal isOpen={showSubOrgForm} onOpenChange={setShowSubOrgForm}>
<ModalContent
title="Create Sub-Organizations"
subTitle="Define a new sub-organization under your current organization."
>
<div className="mb-2">
<NewSubOrganizationForm
onClose={() => {
setShowSubOrgForm(false);
}}
/>
</div>
</ModalContent>
</Modal>
<Modal isOpen={showAdminsModal} onOpenChange={setShowAdminsModal}>
<ModalContent title="Server Administrators" subTitle="View all server administrators">
<div className="mb-2">
@@ -0,0 +1,94 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2";
import { useCreateSubOrganization } from "@app/hooks/api";
import { slugSchema } from "@app/lib/schemas";
type ContentProps = {
onClose: () => void;
};
const AddOrgSchema = z.object({
name: slugSchema()
});
type FormData = z.infer<typeof AddOrgSchema>;
export const NewSubOrganizationForm = ({ onClose }: ContentProps) => {
const createSubOrg = useCreateSubOrganization();
const {
handleSubmit,
control,
formState: { isSubmitting }
} = useForm({
defaultValues: {
name: ""
},
resolver: zodResolver(AddOrgSchema)
});
const navigate = useNavigate();
const router = useRouter();
const onSubmit = async ({ name }: FormData) => {
try {
const { organization } = await createSubOrg.mutateAsync({
name
});
createNotification({
type: "success",
text: "Successfully created sub organization"
});
onClose();
navigate({
to: "/organization/projects",
search: (prev) => ({ ...prev, subOrganization: organization.name })
});
await router.invalidate({ sync: true }).catch(() => null);
} catch {
createNotification({
text: "Failed to create sub organization",
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
helperText="Must be slug-friendly"
errorText={error?.message}
label="Name"
>
<Input autoFocus value={value} onChange={onChange} placeholder="example-team" />
</FormControl>
)}
control={control}
name="name"
/>
<div className="flex w-full gap-4 pt-4">
<Button
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
colorSchema="secondary"
>
Add Sub-Organization
</Button>
<Button onClick={() => onClose()} variant="plain" colorSchema="secondary">
Cancel
</Button>
</div>
</form>
);
};
@@ -3,6 +3,7 @@ import { motion } from "framer-motion";
import { CreateOrgModal } from "@app/components/organization/CreateOrgModal";
import { Tab, TabList, Tabs } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { usePopUp } from "@app/hooks";
type Props = {
@@ -10,10 +11,13 @@ type Props = {
};
export const OrgNavBar = ({ isHidden }: Props) => {
const { isRootOrganization } = useOrganization();
const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const);
const { pathname } = useLocation();
const variant = isRootOrganization ? "org" : "namespace";
return (
<>
{!isHidden && (
@@ -30,28 +34,28 @@ export const OrgNavBar = ({ isHidden }: Props) => {
<TabList className="border-b-0">
<Link to="/organization/projects">
{({ isActive }) => (
<Tab variant="org" value={isActive ? "selected" : ""}>
<Tab variant={variant} value={isActive ? "selected" : ""}>
Overview
</Tab>
)}
</Link>
<Link to="/organization/app-connections">
{({ isActive }) => (
<Tab variant="org" value={isActive ? "selected" : ""}>
<Tab variant={variant} value={isActive ? "selected" : ""}>
App Connections
</Tab>
)}
</Link>
<Link to="/organization/networking">
{({ isActive }) => (
<Tab variant="org" value={isActive ? "selected" : ""}>
<Tab variant={variant} value={isActive ? "selected" : ""}>
Networking
</Tab>
)}
</Link>
<Link to="/organization/secret-sharing">
{({ isActive }) => (
<Tab value={isActive ? "selected" : ""} variant="org">
<Tab value={isActive ? "selected" : ""} variant={variant}>
Secret Sharing
</Tab>
)}
@@ -59,7 +63,7 @@ export const OrgNavBar = ({ isHidden }: Props) => {
<Link to="/organization/access-management">
{({ isActive }) => (
<Tab
variant="org"
variant={variant}
value={
isActive ||
pathname.match(
@@ -75,21 +79,23 @@ export const OrgNavBar = ({ isHidden }: Props) => {
</Link>
<Link to="/organization/audit-logs">
{({ isActive }) => (
<Tab variant="org" value={isActive ? "selected" : ""}>
<Tab variant={variant} value={isActive ? "selected" : ""}>
Audit Logs
</Tab>
)}
</Link>
<Link to="/organization/billing">
{({ isActive }) => (
<Tab variant="org" value={isActive ? "selected" : ""}>
Usage & Billing
</Tab>
)}
</Link>
{isRootOrganization && (
<Link to="/organization/billing">
{({ isActive }) => (
<Tab variant={variant} value={isActive ? "selected" : ""}>
Usage & Billing
</Tab>
)}
</Link>
)}
<Link to="/organization/settings">
{({ isActive }) => (
<Tab variant="org" value={isActive ? "selected" : ""}>
<Tab variant={variant} value={isActive ? "selected" : ""}>
Settings
</Tab>
)}
@@ -171,9 +171,21 @@ export const ProjectSelect = () => {
to: getProjectHomePage(workspace.type, workspace.environments),
params: {
projectId: workspace.id
},
search: {
subOrganization: currentOrg?.subOrganization?.name
}
});
window.location.assign(url.to.replaceAll("$projectId", workspace.id));
const urlInstance = new URL(
`${window.location.origin}/${url.to.replaceAll("$projectId", workspace.id)}`
);
if (currentOrg?.subOrganization) {
urlInstance.searchParams.set(
"subOrganization",
currentOrg.subOrganization.name
);
}
window.location.assign(urlInstance);
}}
icon={
currentWorkspace?.id === workspace.id && (