fix: new sub-org creation + sub-org nav styling

This commit is contained in:
Piyush Gupta
2025-11-28 19:06:40 +05:30
parent 25240789f6
commit 23c4a695f6
5 changed files with 160 additions and 93 deletions

View File

@@ -198,10 +198,12 @@ export const Navbar = () => {
const handleOrgSelection = async ({
organizationId,
subOrganizationId,
navigateTo,
onSuccess
}: {
organizationId?: string;
subOrganizationId?: string;
navigateTo?: string;
onSuccess?: () => void | Promise<void>;
}) => {
if (!organizationId && !subOrganizationId) return;
@@ -234,7 +236,7 @@ export const Navbar = () => {
queryClient.removeQueries({ queryKey: projectKeys.getAllUserProjects() });
await router.invalidate();
await navigateUserToOrg(navigate, targetId);
await navigateUserToOrg({ navigate, organizationId: targetId, navigateTo });
queryClient.removeQueries({ queryKey: subOrgQuery.queryKey });
if (onSuccess) {
@@ -243,21 +245,38 @@ export const Navbar = () => {
};
const handleNavigateToRootOrgBilling = async () => {
const navigateToBilling = async () => {
const navigateToBilling = () => {
navigate({
to: "/organizations/$orgId/billing",
params: { orgId: rootOrg.id }
});
};
const onSuccess = () => {
setShowCardDeclinedModal(false);
};
if (currentOrg.id !== rootOrg.id) {
await handleOrgSelection({ organizationId: rootOrg.id, onSuccess: navigateToBilling });
if (isSubOrganization) {
await handleOrgSelection({ organizationId: rootOrg.id, onSuccess });
} else {
await navigateToBilling();
}
};
const handleNavigateToAdminConsole = async () => {
const navigateToAdminConsole = () => {
navigate({
to: "/admin"
});
};
if (isSubOrganization) {
await handleOrgSelection({ organizationId: rootOrg.id, navigateTo: "/admin" });
} else {
navigateToAdminConsole();
}
};
const { mutateAsync } = useGetOrgTrialUrl();
const logout = useLogoutUser();
@@ -368,14 +387,14 @@ export const Navbar = () => {
<button
className="flex cursor-pointer items-center gap-x-2 truncate whitespace-nowrap"
type="button"
onClick={async () => {
onClick={() => {
if (isSubOrganization) {
handleOrgSelection({ organizationId: currentOrg.rootOrgId as string });
}
navigate({
to: "/organizations/$orgId/projects",
params: { orgId: currentOrg.id }
});
if (isSubOrganization) {
await router.invalidate({ sync: true }).catch(() => null);
}
}}
>
<OrgIcon className={twMerge("size-[14px] shrink-0 text-org")} />
@@ -467,16 +486,18 @@ export const Navbar = () => {
</div>
</DropdownMenuItem>
))}
{Boolean(subOrganizations.length) && (
{Boolean(subOrganizations.length && !isSubOrganization) && (
<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>
{!isSubOrganization && (
<DropdownMenuItem
className="cursor-pointer"
icon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => setShowSubOrgForm(true)}
>
New Sub-Organization
</DropdownMenuItem>
)}{" "}
</DropdownSubMenuContent>
</DropdownSubMenu>
);
@@ -509,71 +530,93 @@ export const Navbar = () => {
</div>
{isSubOrganization && (
<>
<p className="pr-3 pl-1 text-lg text-mineshaft-400/70">/</p>
<DropdownMenu modal={false}>
<Badge
asChild
isTruncatable
variant="sub-org"
// TODO(scott): either add badge size/style variant or create designated component for namespace/org nav bar
className={twMerge(
"gap-x-1.5 text-sm",
isProjectScope &&
"min-w-6 bg-transparent text-mineshaft-200 hover:!bg-transparent hover:underline [&>svg]:!text-sub-org"
)}
>
<Link to="/organizations/$orgId/projects" params={{ orgId: currentOrg.id }}>
<SubOrgIcon className="size-[12px]" />
<span>{currentOrg.name}</span>
</Link>
</Badge>
<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>
<ChevronRight size={18} className="mt-[3px] mr-3 text-mineshaft-400/70" />
<div
className={twMerge(
"relative flex min-w-16 items-center self-end rounded-t-md border-x border-t pt-1.5 pr-2 pb-2.5 pl-3",
!isProjectScope && isSubOrganization
? "border-sub-org/15 bg-gradient-to-b from-sub-org/10 to-sub-org/[0.075]"
: "border-transparent"
)}
>
{/* scott: the below is used to hide the top border from the org nav bar */}
{!isProjectScope && isSubOrganization && (
<div className="absolute -bottom-px left-0 h-px w-full bg-mineshaft-900">
<div className="h-full bg-org/[0.075]" />
</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={() => handleOrgSelection({ subOrganizationId: subOrg.id })}
className="cursor-pointer font-normal"
key={subOrg.id}
)}
<DropdownMenu modal={false}>
<div className="group mr-1 flex min-w-0 cursor-pointer items-center gap-2 overflow-hidden text-sm text-white transition-all duration-100">
<button
className="flex cursor-pointer items-center gap-x-2 truncate whitespace-nowrap"
type="button"
onClick={async () => {
navigate({
to: "/organizations/$orgId/projects",
params: { orgId: currentOrg.id }
});
if (isSubOrganization) {
await router.invalidate({ sync: true }).catch(() => null);
}
}}
>
<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)}
<SubOrgIcon className={twMerge("size-[14px] shrink-0 text-sub-org")} />
<span className="truncate">{currentOrg?.name}</span>
<Badge variant="sub-org" className="hidden lg:inline-flex">
Sub-Organization
</Badge>
</button>
</div>
<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" }}
>
New Sub-Organization
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="px-2 py-1 text-xs text-mineshaft-400 capitalize">
Sub-Organizations
</div>
{subOrganizations.map((subOrg) => (
<DropdownMenuItem
onClick={() => handleOrgSelection({ subOrganizationId: subOrg.id })}
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 && !isSubOrganization) && (
<div className="mt-1 h-1 border-t border-mineshaft-600" />
)}
{!isSubOrganization && (
<DropdownMenuItem
className="cursor-pointer"
icon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => setShowSubOrgForm(true)}
>
New Sub-Organization
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)}
{isProjectScope && (
@@ -618,6 +661,7 @@ export const Navbar = () => {
<Link
className="mr-2 flex h-[34px] items-center rounded-md border border-mineshaft-500 px-2.5 py-1.5 text-sm whitespace-nowrap text-mineshaft-200 hover:bg-mineshaft-600"
to="/admin"
onClick={handleNavigateToAdminConsole}
>
<InstanceIcon className="inline-block size-3.5" />
<span className="ml-2 hidden md:inline-block">Server Console</span>

View File

@@ -1,12 +1,16 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, FormControl, Input } from "@app/components/v2";
import { useCreateSubOrganization } from "@app/hooks/api";
import { projectKeys, subOrganizationsQuery, useCreateSubOrganization } from "@app/hooks/api";
import { authKeys, selectOrganization } from "@app/hooks/api/auth/queries";
import { slugSchema } from "@app/lib/schemas";
import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils";
type ContentProps = {
onClose: () => void;
@@ -20,6 +24,8 @@ type FormData = z.infer<typeof AddOrgSchema>;
export const NewSubOrganizationForm = ({ onClose }: ContentProps) => {
const createSubOrg = useCreateSubOrganization();
const subOrgQuery = subOrganizationsQuery.list({ limit: 500, isAccessible: true });
const queryClient = useQueryClient();
const {
handleSubmit,
@@ -46,11 +52,18 @@ export const NewSubOrganizationForm = ({ onClose }: ContentProps) => {
});
onClose();
navigate({
to: "/organizations/$orgId/projects",
params: { orgId: organization.id }
const { token } = await selectOrganization({
subOrganizationId: organization.id
});
SecurityClient.setToken(token);
SecurityClient.setProviderAuthToken("");
queryClient.removeQueries({ queryKey: authKeys.getAuthToken });
queryClient.removeQueries({ queryKey: projectKeys.getAllUserProjects() });
await router.invalidate({ sync: true }).catch(() => null);
queryClient.removeQueries({ queryKey: subOrgQuery.queryKey });
await navigateUserToOrg({ navigate, organizationId: organization.id });
};
return (

View File

@@ -5,7 +5,17 @@ import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { queryClient } from "@app/hooks/api/reactQuery";
import { userKeys } from "@app/hooks/api/users";
export const navigateUserToOrg = async (navigate: NavigateFn, organizationId?: string) => {
type NavigateUserToOrgParams = {
navigate: NavigateFn;
organizationId?: string;
navigateTo?: string;
};
export const navigateUserToOrg = async ({
navigate,
organizationId,
navigateTo
}: NavigateUserToOrgParams) => {
const userOrgs = await fetchOrganizations();
const nonAuthEnforcedOrgs = userOrgs.filter((org) => !org.authEnforced);
@@ -13,7 +23,7 @@ export const navigateUserToOrg = async (navigate: NavigateFn, organizationId?: s
if (organizationId) {
localStorage.setItem("orgData.id", organizationId);
navigate({
to: "/organizations/$orgId/projects",
to: navigateTo || "/organizations/$orgId/projects",
params: { orgId: organizationId }
});
return;
@@ -24,7 +34,7 @@ export const navigateUserToOrg = async (navigate: NavigateFn, organizationId?: s
const userOrg = nonAuthEnforcedOrgs[0] && nonAuthEnforcedOrgs[0].id;
localStorage.setItem("orgData.id", userOrg);
navigate({
to: "/organizations/$orgId/projects",
to: navigateTo || "/organizations/$orgId/projects",
params: { orgId: userOrg }
});
} else {

View File

@@ -115,7 +115,7 @@ export const PasswordStep = ({
return;
}
await navigateUserToOrg(navigate, organizationId);
await navigateUserToOrg({ navigate, organizationId });
};
await finishWithOrgWorkflow();
@@ -131,7 +131,7 @@ export const PasswordStep = ({
}
// case: no orgs found, so we navigate the user to create an org
else {
await navigateUserToOrg(navigate);
await navigateUserToOrg({ navigate });
}
}
} catch (err: any) {
@@ -233,7 +233,7 @@ export const PasswordStep = ({
}
// case: no orgs found, so we navigate the user to create an org
else {
await navigateUserToOrg(navigate);
await navigateUserToOrg({ navigate });
}
}
} else {
@@ -254,7 +254,7 @@ export const PasswordStep = ({
// case: organization ID is present from the provider auth token -- navigate directly to the org
if (organizationId) {
await navigateUserToOrg(navigate, organizationId);
await navigateUserToOrg({ navigate, organizationId });
}
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
// if the user has no orgs, navigate to the create org page
@@ -264,7 +264,7 @@ export const PasswordStep = ({
if (userOrgs.length > 0) {
navigateToSelectOrganization(undefined, isAdminLogin);
} else {
await navigateUserToOrg(navigate);
await navigateUserToOrg({ navigate });
}
}
}
@@ -316,7 +316,7 @@ export const PasswordStep = ({
return (
<EmailDuplicationConfirmation
onRemoveDuplicateLater={() =>
navigateUserToOrg(navigate, organizationId).catch(() =>
navigateUserToOrg({ navigate, organizationId }).catch(() =>
createNotification({ text: "Failed to navigate user", type: "error" })
)
}

View File

@@ -188,7 +188,7 @@ export const SelectOrganizationSection = () => {
navigate({ to: "/cli-redirect" });
// cli page
} else {
navigateUserToOrg(navigate, organization.id);
navigateUserToOrg({ navigate, organizationId: organization.id });
}
},
[selectOrg]
@@ -201,7 +201,7 @@ export const SelectOrganizationSection = () => {
const decodedJwt = jwtDecode(authToken) as any;
if (decodedJwt?.organizationId) {
navigateUserToOrg(navigate, decodedJwt.organizationId);
navigateUserToOrg({ navigate, organizationId: decodedJwt.organizationId });
}
}