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
@@ -198,10 +198,12 @@ export const Navbar = () => {
const handleOrgSelection = async ({ const handleOrgSelection = async ({
organizationId, organizationId,
subOrganizationId, subOrganizationId,
navigateTo,
onSuccess onSuccess
}: { }: {
organizationId?: string; organizationId?: string;
subOrganizationId?: string; subOrganizationId?: string;
navigateTo?: string;
onSuccess?: () => void | Promise<void>; onSuccess?: () => void | Promise<void>;
}) => { }) => {
if (!organizationId && !subOrganizationId) return; if (!organizationId && !subOrganizationId) return;
@@ -234,7 +236,7 @@ export const Navbar = () => {
queryClient.removeQueries({ queryKey: projectKeys.getAllUserProjects() }); queryClient.removeQueries({ queryKey: projectKeys.getAllUserProjects() });
await router.invalidate(); await router.invalidate();
await navigateUserToOrg(navigate, targetId); await navigateUserToOrg({ navigate, organizationId: targetId, navigateTo });
queryClient.removeQueries({ queryKey: subOrgQuery.queryKey }); queryClient.removeQueries({ queryKey: subOrgQuery.queryKey });
if (onSuccess) { if (onSuccess) {
@@ -243,21 +245,38 @@ export const Navbar = () => {
}; };
const handleNavigateToRootOrgBilling = async () => { const handleNavigateToRootOrgBilling = async () => {
const navigateToBilling = async () => { const navigateToBilling = () => {
navigate({ navigate({
to: "/organizations/$orgId/billing", to: "/organizations/$orgId/billing",
params: { orgId: rootOrg.id } params: { orgId: rootOrg.id }
}); });
};
const onSuccess = () => {
setShowCardDeclinedModal(false); setShowCardDeclinedModal(false);
}; };
if (currentOrg.id !== rootOrg.id) { if (isSubOrganization) {
await handleOrgSelection({ organizationId: rootOrg.id, onSuccess: navigateToBilling }); await handleOrgSelection({ organizationId: rootOrg.id, onSuccess });
} else { } else {
await navigateToBilling(); 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 { mutateAsync } = useGetOrgTrialUrl();
const logout = useLogoutUser(); const logout = useLogoutUser();
@@ -368,14 +387,14 @@ export const Navbar = () => {
<button <button
className="flex cursor-pointer items-center gap-x-2 truncate whitespace-nowrap" className="flex cursor-pointer items-center gap-x-2 truncate whitespace-nowrap"
type="button" type="button"
onClick={async () => { onClick={() => {
if (isSubOrganization) {
handleOrgSelection({ organizationId: currentOrg.rootOrgId as string });
}
navigate({ navigate({
to: "/organizations/$orgId/projects", to: "/organizations/$orgId/projects",
params: { orgId: currentOrg.id } params: { orgId: currentOrg.id }
}); });
if (isSubOrganization) {
await router.invalidate({ sync: true }).catch(() => null);
}
}} }}
> >
<OrgIcon className={twMerge("size-[14px] shrink-0 text-org")} /> <OrgIcon className={twMerge("size-[14px] shrink-0 text-org")} />
@@ -467,16 +486,18 @@ export const Navbar = () => {
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
{Boolean(subOrganizations.length) && ( {Boolean(subOrganizations.length && !isSubOrganization) && (
<div className="mt-1 h-1 border-t border-mineshaft-600" /> <div className="mt-1 h-1 border-t border-mineshaft-600" />
)} )}
<DropdownMenuItem {!isSubOrganization && (
className="cursor-pointer" <DropdownMenuItem
icon={<FontAwesomeIcon icon={faPlus} />} className="cursor-pointer"
onClick={() => setShowSubOrgForm(true)} icon={<FontAwesomeIcon icon={faPlus} />}
> onClick={() => setShowSubOrgForm(true)}
New Sub-Organization >
</DropdownMenuItem> New Sub-Organization
</DropdownMenuItem>
)}{" "}
</DropdownSubMenuContent> </DropdownSubMenuContent>
</DropdownSubMenu> </DropdownSubMenu>
); );
@@ -509,71 +530,93 @@ export const Navbar = () => {
</div> </div>
{isSubOrganization && ( {isSubOrganization && (
<> <>
<p className="pr-3 pl-1 text-lg text-mineshaft-400/70">/</p> <ChevronRight size={18} className="mt-[3px] mr-3 text-mineshaft-400/70" />
<DropdownMenu modal={false}> <div
<Badge className={twMerge(
asChild "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",
isTruncatable !isProjectScope && isSubOrganization
variant="sub-org" ? "border-sub-org/15 bg-gradient-to-b from-sub-org/10 to-sub-org/[0.075]"
// TODO(scott): either add badge size/style variant or create designated component for namespace/org nav bar : "border-transparent"
className={twMerge( )}
"gap-x-1.5 text-sm", >
isProjectScope && {/* scott: the below is used to hide the top border from the org nav bar */}
"min-w-6 bg-transparent text-mineshaft-200 hover:!bg-transparent hover:underline [&>svg]:!text-sub-org" {!isProjectScope && isSubOrganization && (
)} <div className="absolute -bottom-px left-0 h-px w-full bg-mineshaft-900">
> <div className="h-full bg-org/[0.075]" />
<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>
</div> </div>
</DropdownMenuTrigger> )}
<DropdownMenuContent <DropdownMenu modal={false}>
align="center" <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">
side="bottom" <button
className="mt-6 cursor-default p-1 shadow-mineshaft-600 drop-shadow-md" className="flex cursor-pointer items-center gap-x-2 truncate whitespace-nowrap"
style={{ minWidth: "220px" }} type="button"
> onClick={async () => {
<div className="px-2 py-1 text-xs text-mineshaft-400 capitalize"> navigate({
Sub-Organizations to: "/organizations/$orgId/projects",
</div> params: { orgId: currentOrg.id }
{subOrganizations.map((subOrg) => ( });
<DropdownMenuItem if (isSubOrganization) {
onClick={() => handleOrgSelection({ subOrganizationId: subOrg.id })} 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"> <SubOrgIcon className={twMerge("size-[14px] shrink-0 text-sub-org")} />
{currentOrg?.id === subOrg.id && ( <span className="truncate">{currentOrg?.name}</span>
<FontAwesomeIcon icon={faCheck} className="shrink-0 text-primary" /> <Badge variant="sub-org" className="hidden lg:inline-flex">
)} Sub-Organization
<p className="truncate">{subOrg.name}</p> </Badge>
</div> </button>
</DropdownMenuItem> </div>
))} <DropdownMenuTrigger asChild>
{Boolean(subOrganizations.length) && ( <div>
<div className="mt-1 h-1 border-t border-mineshaft-600" /> <IconButton
)} variant="plain"
<DropdownMenuItem colorSchema="secondary"
className="cursor-pointer" ariaLabel="switch-org"
icon={<FontAwesomeIcon icon={faPlus} />} className="px-2 py-1"
onClick={() => setShowSubOrgForm(true)} >
<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 <div className="px-2 py-1 text-xs text-mineshaft-400 capitalize">
</DropdownMenuItem> Sub-Organizations
</DropdownMenuContent> </div>
</DropdownMenu> {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 && ( {isProjectScope && (
@@ -618,6 +661,7 @@ export const Navbar = () => {
<Link <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" 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" to="/admin"
onClick={handleNavigateToAdminConsole}
> >
<InstanceIcon className="inline-block size-3.5" /> <InstanceIcon className="inline-block size-3.5" />
<span className="ml-2 hidden md:inline-block">Server Console</span> <span className="ml-2 hidden md:inline-block">Server Console</span>
@@ -1,12 +1,16 @@
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate, useRouter } from "@tanstack/react-router"; import { useNavigate, useRouter } from "@tanstack/react-router";
import { z } from "zod"; import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, FormControl, Input } from "@app/components/v2"; 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 { slugSchema } from "@app/lib/schemas";
import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils";
type ContentProps = { type ContentProps = {
onClose: () => void; onClose: () => void;
@@ -20,6 +24,8 @@ type FormData = z.infer<typeof AddOrgSchema>;
export const NewSubOrganizationForm = ({ onClose }: ContentProps) => { export const NewSubOrganizationForm = ({ onClose }: ContentProps) => {
const createSubOrg = useCreateSubOrganization(); const createSubOrg = useCreateSubOrganization();
const subOrgQuery = subOrganizationsQuery.list({ limit: 500, isAccessible: true });
const queryClient = useQueryClient();
const { const {
handleSubmit, handleSubmit,
@@ -46,11 +52,18 @@ export const NewSubOrganizationForm = ({ onClose }: ContentProps) => {
}); });
onClose(); onClose();
navigate({ const { token } = await selectOrganization({
to: "/organizations/$orgId/projects", subOrganizationId: organization.id
params: { orgId: organization.id }
}); });
SecurityClient.setToken(token);
SecurityClient.setProviderAuthToken("");
queryClient.removeQueries({ queryKey: authKeys.getAuthToken });
queryClient.removeQueries({ queryKey: projectKeys.getAllUserProjects() });
await router.invalidate({ sync: true }).catch(() => null); await router.invalidate({ sync: true }).catch(() => null);
queryClient.removeQueries({ queryKey: subOrgQuery.queryKey });
await navigateUserToOrg({ navigate, organizationId: organization.id });
}; };
return ( return (
@@ -5,7 +5,17 @@ import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { queryClient } from "@app/hooks/api/reactQuery"; import { queryClient } from "@app/hooks/api/reactQuery";
import { userKeys } from "@app/hooks/api/users"; 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 userOrgs = await fetchOrganizations();
const nonAuthEnforcedOrgs = userOrgs.filter((org) => !org.authEnforced); const nonAuthEnforcedOrgs = userOrgs.filter((org) => !org.authEnforced);
@@ -13,7 +23,7 @@ export const navigateUserToOrg = async (navigate: NavigateFn, organizationId?: s
if (organizationId) { if (organizationId) {
localStorage.setItem("orgData.id", organizationId); localStorage.setItem("orgData.id", organizationId);
navigate({ navigate({
to: "/organizations/$orgId/projects", to: navigateTo || "/organizations/$orgId/projects",
params: { orgId: organizationId } params: { orgId: organizationId }
}); });
return; return;
@@ -24,7 +34,7 @@ export const navigateUserToOrg = async (navigate: NavigateFn, organizationId?: s
const userOrg = nonAuthEnforcedOrgs[0] && nonAuthEnforcedOrgs[0].id; const userOrg = nonAuthEnforcedOrgs[0] && nonAuthEnforcedOrgs[0].id;
localStorage.setItem("orgData.id", userOrg); localStorage.setItem("orgData.id", userOrg);
navigate({ navigate({
to: "/organizations/$orgId/projects", to: navigateTo || "/organizations/$orgId/projects",
params: { orgId: userOrg } params: { orgId: userOrg }
}); });
} else { } else {
@@ -115,7 +115,7 @@ export const PasswordStep = ({
return; return;
} }
await navigateUserToOrg(navigate, organizationId); await navigateUserToOrg({ navigate, organizationId });
}; };
await finishWithOrgWorkflow(); await finishWithOrgWorkflow();
@@ -131,7 +131,7 @@ export const PasswordStep = ({
} }
// case: no orgs found, so we navigate the user to create an org // case: no orgs found, so we navigate the user to create an org
else { else {
await navigateUserToOrg(navigate); await navigateUserToOrg({ navigate });
} }
} }
} catch (err: any) { } catch (err: any) {
@@ -233,7 +233,7 @@ export const PasswordStep = ({
} }
// case: no orgs found, so we navigate the user to create an org // case: no orgs found, so we navigate the user to create an org
else { else {
await navigateUserToOrg(navigate); await navigateUserToOrg({ navigate });
} }
} }
} else { } else {
@@ -254,7 +254,7 @@ export const PasswordStep = ({
// case: organization ID is present from the provider auth token -- navigate directly to the org // case: organization ID is present from the provider auth token -- navigate directly to the org
if (organizationId) { 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 // 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 // if the user has no orgs, navigate to the create org page
@@ -264,7 +264,7 @@ export const PasswordStep = ({
if (userOrgs.length > 0) { if (userOrgs.length > 0) {
navigateToSelectOrganization(undefined, isAdminLogin); navigateToSelectOrganization(undefined, isAdminLogin);
} else { } else {
await navigateUserToOrg(navigate); await navigateUserToOrg({ navigate });
} }
} }
} }
@@ -316,7 +316,7 @@ export const PasswordStep = ({
return ( return (
<EmailDuplicationConfirmation <EmailDuplicationConfirmation
onRemoveDuplicateLater={() => onRemoveDuplicateLater={() =>
navigateUserToOrg(navigate, organizationId).catch(() => navigateUserToOrg({ navigate, organizationId }).catch(() =>
createNotification({ text: "Failed to navigate user", type: "error" }) createNotification({ text: "Failed to navigate user", type: "error" })
) )
} }
@@ -188,7 +188,7 @@ export const SelectOrganizationSection = () => {
navigate({ to: "/cli-redirect" }); navigate({ to: "/cli-redirect" });
// cli page // cli page
} else { } else {
navigateUserToOrg(navigate, organization.id); navigateUserToOrg({ navigate, organizationId: organization.id });
} }
}, },
[selectOrg] [selectOrg]
@@ -201,7 +201,7 @@ export const SelectOrganizationSection = () => {
const decodedJwt = jwtDecode(authToken) as any; const decodedJwt = jwtDecode(authToken) as any;
if (decodedJwt?.organizationId) { if (decodedJwt?.organizationId) {
navigateUserToOrg(navigate, decodedJwt.organizationId); navigateUserToOrg({ navigate, organizationId: decodedJwt.organizationId });
} }
} }