mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
chore: merge main
This commit is contained in:
@@ -24,8 +24,6 @@ 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
|
||||
@@ -40,17 +38,6 @@ apiRequest.interceptors.request.use((config) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
config.headers.Authorization = `Bearer ${providerAuthToken}`;
|
||||
}
|
||||
|
||||
const rootOrgHeader = config.headers.get("x-root-org");
|
||||
|
||||
if (rootOrgHeader) {
|
||||
config.headers.delete("x-root-org");
|
||||
} else {
|
||||
const subOrganization = params.get("subOrganization");
|
||||
if (subOrganization) {
|
||||
config.headers.set("x-infisical-org", subOrganization);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
|
||||
@@ -61,7 +61,8 @@ export const leaveConfirmDefaultMessage =
|
||||
export enum SessionStorageKeys {
|
||||
CLI_TERMINAL_TOKEN = "CLI_TERMINAL_TOKEN",
|
||||
ORG_LOGIN_SUCCESS_REDIRECT_URL = "ORG_LOGIN_SUCCESS_REDIRECT_URL",
|
||||
AUTH_CONSENT = "AUTH_CONSENT"
|
||||
AUTH_CONSENT = "AUTH_CONSENT",
|
||||
MFA_TEMP_TOKEN = "MFA_TEMP_TOKEN"
|
||||
}
|
||||
|
||||
export const secretTagsColors = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useRouteContext, useSearch } from "@tanstack/react-router";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
|
||||
import { fetchOrganizationById, organizationKeys } from "@app/hooks/api/organization/queries";
|
||||
|
||||
@@ -10,28 +10,23 @@ export const useOrganization = () => {
|
||||
select: (el) => el.organizationId
|
||||
});
|
||||
|
||||
const subOrganization = useSearch({
|
||||
strict: false,
|
||||
select: (el) => el?.subOrganization
|
||||
});
|
||||
|
||||
const { data: currentOrg } = useSuspenseQuery({
|
||||
queryKey: organizationKeys.getOrgById(organizationId, subOrganization || "root"),
|
||||
queryKey: organizationKeys.getOrgById(organizationId),
|
||||
queryFn: () => fetchOrganizationById(organizationId),
|
||||
staleTime: Infinity
|
||||
});
|
||||
const isSubOrganization = currentOrg.id !== currentOrg.rootOrgId && Boolean(currentOrg.rootOrgId);
|
||||
|
||||
const org = useMemo(
|
||||
() => ({
|
||||
currentOrg: {
|
||||
...currentOrg,
|
||||
id: currentOrg?.subOrganization?.id || currentOrg?.id,
|
||||
parentOrgId: currentOrg.id
|
||||
parentOrgId: isSubOrganization ? currentOrg?.parentOrgId : null
|
||||
},
|
||||
isSubOrganization: Boolean(currentOrg.subOrganization),
|
||||
isRootOrganization: !currentOrg.subOrganization
|
||||
isSubOrganization,
|
||||
isRootOrganization: !isSubOrganization
|
||||
}),
|
||||
[currentOrg, subOrganization]
|
||||
[currentOrg]
|
||||
);
|
||||
|
||||
return org;
|
||||
|
||||
@@ -58,10 +58,12 @@ export const loginLDAPRedirect = async (loginLDAPDetails: LoginLDAPDTO) => {
|
||||
return data;
|
||||
};
|
||||
|
||||
export const selectOrganization = async (data: {
|
||||
export type SelectOrganizationParams = {
|
||||
organizationId: string;
|
||||
userAgent?: UserAgentType;
|
||||
}) => {
|
||||
};
|
||||
|
||||
export const selectOrganization = async (data: SelectOrganizationParams) => {
|
||||
const { data: res } = await apiRequest.post<{
|
||||
token: string;
|
||||
isMfaEnabled: boolean;
|
||||
@@ -73,7 +75,7 @@ export const selectOrganization = async (data: {
|
||||
export const useSelectOrganization = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (details: { organizationId: string; userAgent?: UserAgentType }) => {
|
||||
mutationFn: async (details: SelectOrganizationParams) => {
|
||||
const data = await selectOrganization(details);
|
||||
|
||||
// If a custom user agent is set, then this session is meant for another consuming application, not the web application.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type GetAuthTokenAPI = {
|
||||
token: string;
|
||||
organizationId?: string;
|
||||
subOrganizationId?: string;
|
||||
};
|
||||
|
||||
export enum UserEncryptionVersion {
|
||||
|
||||
@@ -42,7 +42,7 @@ export const organizationKeys = {
|
||||
[...organizationKeys.getOrgIdentityMemberships(orgId), params] as const,
|
||||
getOrgGroups: (orgId: string) => [{ orgId }, "organization-groups"] as const,
|
||||
getOrgIntegrationAuths: (orgId: string) => [{ orgId }, "integration-auths"] as const,
|
||||
getOrgById: (orgId: string, subOrg?: string) => ["organization", { orgId, subOrg }],
|
||||
getOrgById: (orgId: string) => ["organization", { orgId }],
|
||||
getAvailableIdentities: () => ["available-identities"],
|
||||
getAvailableUsers: () => ["available-users"]
|
||||
};
|
||||
@@ -67,7 +67,7 @@ export const fetchOrganizationById = async (id: string) => {
|
||||
const {
|
||||
data: { organization }
|
||||
} = await apiRequest.get<{
|
||||
organization: Organization & { subOrganization?: { id: string; name: string } };
|
||||
organization: Organization;
|
||||
}>(`/api/v1/organization/${id}`);
|
||||
return organization;
|
||||
};
|
||||
|
||||
@@ -30,6 +30,8 @@ export type Organization = {
|
||||
maxSharedSecretLifetime: number;
|
||||
maxSharedSecretViewLimit: number | null;
|
||||
blockDuplicateSecretSyncDestinations: boolean;
|
||||
parentOrgId: string | null;
|
||||
rootOrgId: string | null;
|
||||
};
|
||||
|
||||
export type UpdateOrgDTO = {
|
||||
|
||||
@@ -3,14 +3,16 @@ import { WorkflowIntegrationPlatform } from "../workflowIntegrations/types";
|
||||
import { TListProjectIdentitiesDTO, TSearchProjectsDTO } from "./types";
|
||||
|
||||
export const projectKeys = {
|
||||
getProjectById: (projectId: string) => ["projects", { projectId }] as const,
|
||||
allProjectQueries: () => ["projects"] as const,
|
||||
getProjectById: (projectId: string) =>
|
||||
[...projectKeys.allProjectQueries(), { projectId }] as const,
|
||||
getProjectSecrets: (projectId: string) => [{ projectId }, "project-secrets"] as const,
|
||||
getProjectIndexStatus: (projectId: string) => [{ projectId }, "project-index-status"] as const,
|
||||
getProjectUpgradeStatus: (projectId: string) => [{ projectId }, "project-upgrade-status"],
|
||||
getProjectMemberships: (orgId: string) => [{ orgId }, "project-memberships"],
|
||||
getProjectAuthorization: (projectId: string) => [{ projectId }, "project-authorizations"],
|
||||
getProjectIntegrations: (projectId: string) => [{ projectId }, "project-integrations"],
|
||||
getAllUserProjects: () => ["projects"] as const,
|
||||
getAllUserProjects: () => [...projectKeys.allProjectQueries()] as const,
|
||||
getProjectAuditLogs: (projectId: string) => [{ projectId }, "project-audit-logs"] as const,
|
||||
getProjectUsers: (
|
||||
projectId: string,
|
||||
@@ -28,7 +30,8 @@ export const projectKeys = {
|
||||
// allows invalidation using above key without knowing params
|
||||
getProjectIdentityMembershipsWithParams: ({ projectId, ...params }: TListProjectIdentitiesDTO) =>
|
||||
[...projectKeys.getProjectIdentityMemberships(projectId), params] as const,
|
||||
searchProject: (dto: TSearchProjectsDTO) => ["search-projects", dto] as const,
|
||||
searchProject: (dto: TSearchProjectsDTO) =>
|
||||
[...projectKeys.allProjectQueries(), "search-projects", dto] as const,
|
||||
getProjectGroupMemberships: (projectId: string) => [{ projectId }, "project-groups"] as const,
|
||||
getProjectGroupMembershipDetails: (projectId: string, groupId: string) =>
|
||||
[{ projectId, groupId }, "project-group-membership-details"] as const,
|
||||
|
||||
@@ -11,10 +11,7 @@ export const useCreateSubOrganization = () => {
|
||||
mutationFn: async (dto: TCreateSubOrganizationDTO) => {
|
||||
const { data } = await apiRequest.post<{ organization: TSubOrganization }>(
|
||||
"/api/v1/sub-organizations",
|
||||
dto,
|
||||
{
|
||||
headers: { "x-root-org": "discard" } // akhi/scott: this just tells the request to use the root org ID header
|
||||
}
|
||||
dto
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -80,7 +80,11 @@ const getPlan = (subscription: SubscriptionPlan) => {
|
||||
return "Free";
|
||||
};
|
||||
|
||||
const getFormattedSupportEmailLink = (variables: { org_id: string; domain: string }) => {
|
||||
const getFormattedSupportEmailLink = (variables: {
|
||||
org_id: string;
|
||||
domain: string;
|
||||
root_org_id?: string;
|
||||
}) => {
|
||||
const email = "support@infisical.com";
|
||||
|
||||
const body = `Hello Infisical Support Team,
|
||||
@@ -94,6 +98,7 @@ Issue Details:
|
||||
|
||||
Account Info:
|
||||
- Organization ID: ${variables.org_id}
|
||||
${variables.root_org_id ? `- Root Organization ID: ${variables.root_org_id}` : ""}
|
||||
- Domain: ${variables.domain}
|
||||
|
||||
Thank you,
|
||||
@@ -169,6 +174,10 @@ export const Navbar = () => {
|
||||
|
||||
const isModalIntrusive = Boolean(!isBillingPage && isCardDeclinedMoreThan30Days);
|
||||
|
||||
const rootOrg = isSubOrganization
|
||||
? orgs?.find((org) => org.id === currentOrg.rootOrgId) || currentOrg
|
||||
: currentOrg;
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalIntrusive) {
|
||||
setShowCardDeclinedModal(true);
|
||||
@@ -182,10 +191,20 @@ export const Navbar = () => {
|
||||
}
|
||||
}, [subscription, isBillingPage, isModalIntrusive]);
|
||||
|
||||
const handleOrgChange = async (orgId: string) => {
|
||||
const { token, isMfaEnabled, mfaMethod } = await selectOrganization({
|
||||
organizationId: orgId
|
||||
});
|
||||
const handleOrgSelection = async ({
|
||||
organizationId,
|
||||
navigateTo,
|
||||
onSuccess
|
||||
}: {
|
||||
organizationId?: string;
|
||||
navigateTo?: string;
|
||||
onSuccess?: () => void | Promise<void>;
|
||||
}) => {
|
||||
if (!organizationId) return;
|
||||
|
||||
if (organizationId === currentOrg.id) return;
|
||||
|
||||
const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ organizationId });
|
||||
|
||||
if (isMfaEnabled) {
|
||||
SecurityClient.setMfaToken(token);
|
||||
@@ -193,14 +212,58 @@ export const Navbar = () => {
|
||||
setRequiredMfaMethod(mfaMethod);
|
||||
}
|
||||
toggleShowMfa.on();
|
||||
setMfaSuccessCallback(() => () => handleOrgChange(orgId));
|
||||
setMfaSuccessCallback(() => async () => {
|
||||
await handleOrgSelection({ organizationId, onSuccess });
|
||||
});
|
||||
return;
|
||||
}
|
||||
await router.invalidate();
|
||||
await navigateUserToOrg(navigate, orgId);
|
||||
queryClient.removeQueries({ queryKey: subOrgQuery.queryKey });
|
||||
|
||||
SecurityClient.setToken(token);
|
||||
SecurityClient.setProviderAuthToken("");
|
||||
queryClient.removeQueries({ queryKey: authKeys.getAuthToken });
|
||||
queryClient.removeQueries({ queryKey: projectKeys.getAllUserProjects() });
|
||||
queryClient.removeQueries({ queryKey: subOrgQuery.queryKey });
|
||||
|
||||
await queryClient.refetchQueries({ queryKey: authKeys.getAuthToken });
|
||||
|
||||
await navigateUserToOrg({ navigate, organizationId, navigateTo });
|
||||
queryClient.removeQueries({ queryKey: projectKeys.allProjectQueries() });
|
||||
|
||||
if (onSuccess) {
|
||||
await onSuccess();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigateToRootOrgBilling = async () => {
|
||||
const navigateToBilling = () => {
|
||||
navigate({
|
||||
to: "/organizations/$orgId/billing",
|
||||
params: { orgId: rootOrg.id }
|
||||
});
|
||||
};
|
||||
|
||||
const onSuccess = () => {
|
||||
setShowCardDeclinedModal(false);
|
||||
};
|
||||
|
||||
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();
|
||||
@@ -272,7 +335,7 @@ export const Navbar = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
handleOrgChange(org?.id);
|
||||
handleOrgSelection({ organizationId: org?.id });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -314,17 +377,20 @@ export const Navbar = () => {
|
||||
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);
|
||||
await handleOrgSelection({
|
||||
organizationId: currentOrg.rootOrgId as string
|
||||
});
|
||||
} else {
|
||||
navigate({
|
||||
to: "/organizations/$orgId/projects",
|
||||
params: { orgId: currentOrg.id }
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<OrgIcon className={twMerge("size-[14px] shrink-0 text-org")} />
|
||||
<span className="truncate">{currentOrg?.name}</span>
|
||||
<span className="truncate">{rootOrg?.name}</span>
|
||||
<Badge variant="org" className="hidden lg:inline-flex">
|
||||
Organization
|
||||
</Badge>
|
||||
@@ -397,13 +463,7 @@ export const Navbar = () => {
|
||||
</div>
|
||||
{subOrganizations.map((subOrg) => (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
navigate({
|
||||
to: "/organizations/$orgId/projects",
|
||||
params: { orgId: subOrg.id }
|
||||
});
|
||||
await router.invalidate({ sync: true }).catch(() => null);
|
||||
}}
|
||||
onClick={() => handleOrgSelection({ organizationId: subOrg.id })}
|
||||
className="cursor-pointer font-normal"
|
||||
key={subOrg.id}
|
||||
>
|
||||
@@ -458,79 +518,93 @@ export const Navbar = () => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{currentOrg.subOrganization && (
|
||||
{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.subOrganization.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-sub-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
|
||||
)}
|
||||
<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: subOrg.id }
|
||||
params: { orgId: currentOrg.id }
|
||||
});
|
||||
await router.invalidate({ sync: true }).catch(() => null);
|
||||
if (isSubOrganization) {
|
||||
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)}
|
||||
<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({ organizationId: 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) && (
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isProjectScope && (
|
||||
@@ -550,11 +624,11 @@ export const Navbar = () => {
|
||||
className="mr-2 border-mineshaft-500 px-2.5 py-1.5 whitespace-nowrap text-mineshaft-200 hover:bg-mineshaft-600"
|
||||
leftIcon={<FontAwesomeIcon icon={faInfinity} />}
|
||||
onClick={async () => {
|
||||
if (!subscription || !currentOrg) return;
|
||||
if (!subscription || !rootOrg) return;
|
||||
|
||||
// direct user to start pro trial
|
||||
const url = await mutateAsync({
|
||||
orgId: currentOrg.id,
|
||||
orgId: rootOrg.id,
|
||||
success_url: window.location.href
|
||||
});
|
||||
|
||||
@@ -575,6 +649,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>
|
||||
@@ -612,7 +687,8 @@ export const Navbar = () => {
|
||||
text === "Email Support"
|
||||
? getUrl({
|
||||
org_id: currentOrg.id,
|
||||
domain: window.location.origin
|
||||
domain: window.location.origin,
|
||||
...(isSubOrganization && { root_org_id: rootOrg.id })
|
||||
})
|
||||
: getUrl();
|
||||
|
||||
@@ -772,19 +848,13 @@ export const Navbar = () => {
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="flex space-x-3">
|
||||
<Link
|
||||
to="/organizations/$orgId/billing"
|
||||
params={{ orgId: currentOrg.id }}
|
||||
className="inline-flex"
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
variant="solid"
|
||||
onClick={handleNavigateToRootOrgBilling}
|
||||
>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
variant="solid"
|
||||
onClick={() => setShowCardDeclinedModal(false)}
|
||||
>
|
||||
Update Payment Method
|
||||
</Button>
|
||||
</Link>
|
||||
Update Payment Method
|
||||
</Button>
|
||||
{!isModalIntrusive && (
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
@@ -810,6 +880,7 @@ export const Navbar = () => {
|
||||
onClose={() => {
|
||||
setShowSubOrgForm(false);
|
||||
}}
|
||||
handleOrgSelection={handleOrgSelection}
|
||||
/>
|
||||
</div>
|
||||
</ModalContent>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
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 SecurityClient from "@app/components/utilities/SecurityClient";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useCreateSubOrganization } from "@app/hooks/api";
|
||||
import { selectOrganization } from "@app/hooks/api/auth/queries";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
type ContentProps = {
|
||||
onClose: () => void;
|
||||
handleOrgSelection: (params: { organizationId: string }) => void;
|
||||
};
|
||||
|
||||
const AddOrgSchema = z.object({
|
||||
@@ -18,7 +21,8 @@ const AddOrgSchema = z.object({
|
||||
|
||||
type FormData = z.infer<typeof AddOrgSchema>;
|
||||
|
||||
export const NewSubOrganizationForm = ({ onClose }: ContentProps) => {
|
||||
export const NewSubOrganizationForm = ({ onClose, handleOrgSelection }: ContentProps) => {
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
const createSubOrg = useCreateSubOrganization();
|
||||
|
||||
const {
|
||||
@@ -32,10 +36,16 @@ export const NewSubOrganizationForm = ({ onClose }: ContentProps) => {
|
||||
resolver: zodResolver(AddOrgSchema)
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
|
||||
const onSubmit = async ({ name }: FormData) => {
|
||||
if (isSubOrganization && currentOrg.rootOrgId) {
|
||||
const { token } = await selectOrganization({
|
||||
organizationId: currentOrg.rootOrgId
|
||||
});
|
||||
|
||||
SecurityClient.setToken(token);
|
||||
SecurityClient.setProviderAuthToken("");
|
||||
}
|
||||
|
||||
const { organization } = await createSubOrg.mutateAsync({
|
||||
name
|
||||
});
|
||||
@@ -46,11 +56,7 @@ export const NewSubOrganizationForm = ({ onClose }: ContentProps) => {
|
||||
});
|
||||
onClose();
|
||||
|
||||
navigate({
|
||||
to: "/organizations/$orgId/projects",
|
||||
params: { orgId: organization.id }
|
||||
});
|
||||
await router.invalidate({ sync: true }).catch(() => null);
|
||||
await handleOrgSelection({ organizationId: organization.id });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link, useLocation } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { CreateOrgModal } from "@app/components/organization/CreateOrgModal";
|
||||
import { Tab, TabList, Tabs } from "@app/components/v2";
|
||||
@@ -21,7 +22,14 @@ export const OrgNavBar = ({ isHidden }: Props) => {
|
||||
return (
|
||||
<div className="bg-mineshaft-900">
|
||||
{!isHidden && (
|
||||
<div className="dark flex w-full flex-col overflow-x-hidden border-y border-t-org/15 border-b-org/5 bg-gradient-to-b from-org/[0.075] to-org/[0.025] px-4 pt-0.5">
|
||||
<div
|
||||
className={twMerge(
|
||||
"dark flex w-full flex-col overflow-x-hidden border-y bg-gradient-to-b px-4 pt-0.5",
|
||||
isRootOrganization
|
||||
? "border-t-org/15 border-b-org/5 from-org/[0.075] to-org/[0.025]"
|
||||
: "border-t-sub-org/15 border-b-sub-org/5 from-sub-org/[0.075] to-sub-org/[0.025]"
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
key="menu-org-items"
|
||||
initial={{ x: -150 }}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useRemoveAssumeProjectPrivilege } from "@app/hooks/api";
|
||||
import { ActorType } from "@app/hooks/api/auditLogs/enums";
|
||||
|
||||
export const AssumePrivilegeModeBanner = () => {
|
||||
const { isSubOrganization, currentOrg } = useOrganization();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentProject } = useProject();
|
||||
const exitAssumePrivilegeMode = useRemoveAssumeProjectPrivilege();
|
||||
const { assumedPrivilegeDetails } = useProjectPermission();
|
||||
@@ -37,7 +37,7 @@ export const AssumePrivilegeModeBanner = () => {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
const url = `${getProjectHomePage(currentProject.type, currentProject.environments)}${isSubOrganization ? `?subOrganization=${currentOrg.slug}` : ""}`;
|
||||
const url = getProjectHomePage(currentProject.type, currentProject.environments);
|
||||
window.location.assign(
|
||||
url.replace("$orgId", currentOrg.id).replace("$projectId", currentProject.id)
|
||||
);
|
||||
|
||||
@@ -157,20 +157,11 @@ const ProjectSelectInner = () => {
|
||||
params: {
|
||||
projectId: workspace.id,
|
||||
orgId: workspace.orgId
|
||||
},
|
||||
search: {
|
||||
subOrganization: currentOrg?.subOrganization?.name
|
||||
}
|
||||
});
|
||||
const urlInstance = new URL(
|
||||
`${window.location.origin}${url.to.replaceAll("$orgId", url.params.orgId).replaceAll("$projectId", url.params.projectId)}`
|
||||
);
|
||||
if (currentOrg?.subOrganization) {
|
||||
urlInstance.searchParams.set(
|
||||
"subOrganization",
|
||||
currentOrg.subOrganization.name
|
||||
);
|
||||
}
|
||||
window.location.assign(urlInstance);
|
||||
}}
|
||||
icon={
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" })
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export const SelectOrganizationSection = () => {
|
||||
const orgId = queryParams.get("org_id");
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
const isAdminLogin = queryParams.get("is_admin_login") === "true";
|
||||
const mfaPending = queryParams.get("mfa_pending") === "true";
|
||||
const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId);
|
||||
|
||||
const logout = useLogoutUser(true);
|
||||
@@ -188,7 +189,7 @@ export const SelectOrganizationSection = () => {
|
||||
navigate({ to: "/cli-redirect" });
|
||||
// cli page
|
||||
} else {
|
||||
navigateUserToOrg(navigate, organization.id);
|
||||
navigateUserToOrg({ navigate, organizationId: organization.id });
|
||||
}
|
||||
},
|
||||
[selectOrg]
|
||||
@@ -201,7 +202,7 @@ export const SelectOrganizationSection = () => {
|
||||
const decodedJwt = jwtDecode(authToken) as any;
|
||||
|
||||
if (decodedJwt?.organizationId) {
|
||||
navigateUserToOrg(navigate, decodedJwt.organizationId);
|
||||
navigateUserToOrg({ navigate, organizationId: decodedJwt.organizationId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,10 +237,22 @@ export const SelectOrganizationSection = () => {
|
||||
}, [organizations.isPending, organizations.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mfaPending && defaultSelectedOrg) {
|
||||
const storedMfaToken = sessionStorage.getItem(SessionStorageKeys.MFA_TEMP_TOKEN);
|
||||
if (storedMfaToken) {
|
||||
sessionStorage.removeItem(SessionStorageKeys.MFA_TEMP_TOKEN);
|
||||
SecurityClient.setMfaToken(storedMfaToken);
|
||||
setIsInitialOrgCheckLoading(false);
|
||||
toggleShowMfa.on();
|
||||
setMfaSuccessCallback(() => () => handleSelectOrganization(defaultSelectedOrg));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (defaultSelectedOrg) {
|
||||
handleSelectOrganization(defaultSelectedOrg);
|
||||
}
|
||||
}, [defaultSelectedOrg]);
|
||||
}, [defaultSelectedOrg, mfaPending]);
|
||||
|
||||
if (
|
||||
userLoading ||
|
||||
|
||||
@@ -8,7 +8,8 @@ export const SelectOrganizationPageQueryParams = z.object({
|
||||
org_id: z.string().optional().catch(""),
|
||||
callback_port: z.coerce.number().optional().catch(undefined),
|
||||
is_admin_login: z.boolean().optional().catch(false),
|
||||
force: z.boolean().optional()
|
||||
force: z.boolean().optional(),
|
||||
mfa_pending: z.boolean().optional().catch(false)
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/_restrict-login-signup/login/select-organization")({
|
||||
@@ -16,7 +17,12 @@ export const Route = createFileRoute("/_restrict-login-signup/login/select-organ
|
||||
validateSearch: zodValidator(SelectOrganizationPageQueryParams),
|
||||
search: {
|
||||
middlewares: [
|
||||
stripSearchParams({ org_id: "", callback_port: undefined, is_admin_login: false })
|
||||
stripSearchParams({
|
||||
org_id: "",
|
||||
callback_port: undefined,
|
||||
is_admin_login: false,
|
||||
mfa_pending: false
|
||||
})
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ const tabs = [
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
|
||||
@@ -34,7 +34,8 @@ export const SettingsPage = () => {
|
||||
}}
|
||||
className="flex items-center gap-x-1.5 text-xs whitespace-nowrap text-neutral hover:underline"
|
||||
>
|
||||
<InfoIcon size={12} /> Looking for organization settings?
|
||||
<InfoIcon size={12} /> Looking for {isSubOrganization ? "sub-" : ""}organization
|
||||
settings?
|
||||
</Link>
|
||||
</PageHeader>
|
||||
<Tabs orientation="vertical" defaultValue={tabs[0].key}>
|
||||
|
||||
@@ -19,7 +19,7 @@ const tabs = [
|
||||
export const SettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
|
||||
@@ -39,7 +39,8 @@ export const SettingsPage = () => {
|
||||
}}
|
||||
className="flex items-center gap-x-1.5 text-xs whitespace-nowrap text-neutral hover:underline"
|
||||
>
|
||||
<InfoIcon size={12} /> Looking for organization settings?
|
||||
<InfoIcon size={12} /> Looking for {isSubOrganization ? "sub-" : ""}organization
|
||||
settings?
|
||||
</Link>
|
||||
</PageHeader>
|
||||
<Tabs orientation="vertical" defaultValue={tabs[0].key}>
|
||||
|
||||
@@ -73,6 +73,11 @@ export const Route = createFileRoute("/_authenticate")({
|
||||
});
|
||||
});
|
||||
|
||||
return { organizationId: data.organizationId as string, isAuthenticated: true, user };
|
||||
const isSubOrganization = !!data.subOrganizationId;
|
||||
return {
|
||||
organizationId: isSubOrganization ? data.subOrganizationId : (data.organizationId as string),
|
||||
isAuthenticated: true,
|
||||
user
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, isRedirect, redirect } from "@tanstack/react-router";
|
||||
|
||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||
import { SessionStorageKeys } from "@app/const";
|
||||
import { authKeys, fetchAuthToken, selectOrganization } from "@app/hooks/api/auth/queries";
|
||||
import { fetchOrganizationById, organizationKeys } from "@app/hooks/api/organization/queries";
|
||||
import { projectKeys } from "@app/hooks/api/projects";
|
||||
import { fetchUserOrgPermissions, roleQueryKeys } from "@app/hooks/api/roles/queries";
|
||||
import { subOrganizationsQuery } from "@app/hooks/api/subOrganizations";
|
||||
import { fetchOrgSubscription, subscriptionQueryKeys } from "@app/hooks/api/subscriptions/queries";
|
||||
|
||||
// Route context to fill in organization's data like details, subscription etc
|
||||
@@ -15,6 +20,44 @@ export const Route = createFileRoute("/_authenticate/_inject-org-details")({
|
||||
organizationId = context.organizationId!;
|
||||
}
|
||||
|
||||
if ((params as { orgId?: string })?.orgId && context.organizationId) {
|
||||
const urlOrgId = (params as { orgId: string }).orgId;
|
||||
const currentTokenOrgId = context.organizationId;
|
||||
|
||||
if (urlOrgId !== currentTokenOrgId) {
|
||||
try {
|
||||
const { token, isMfaEnabled } = await selectOrganization({ organizationId: urlOrgId });
|
||||
|
||||
if (isMfaEnabled) {
|
||||
sessionStorage.setItem(SessionStorageKeys.MFA_TEMP_TOKEN, token);
|
||||
throw redirect({
|
||||
to: "/login/select-organization",
|
||||
search: { org_id: urlOrgId, mfa_pending: true }
|
||||
});
|
||||
}
|
||||
|
||||
if (!isMfaEnabled && token) {
|
||||
SecurityClient.setToken(token);
|
||||
SecurityClient.setProviderAuthToken("");
|
||||
|
||||
context.queryClient.removeQueries({ queryKey: authKeys.getAuthToken });
|
||||
context.queryClient.removeQueries({ queryKey: projectKeys.getAllUserProjects() });
|
||||
context.queryClient.removeQueries({ queryKey: subOrganizationsQuery.allKey() });
|
||||
|
||||
await context.queryClient.fetchQuery({
|
||||
queryKey: authKeys.getAuthToken,
|
||||
queryFn: fetchAuthToken
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (isRedirect(error)) {
|
||||
throw error;
|
||||
}
|
||||
console.warn("Failed to automatically exchange token for organization:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await context.queryClient.ensureQueryData({
|
||||
queryKey: organizationKeys.getOrgById(organizationId),
|
||||
queryFn: () => fetchOrganizationById(organizationId)
|
||||
|
||||
@@ -117,9 +117,10 @@ export const Route = createFileRoute("/_restrict-login-signup")({
|
||||
return;
|
||||
throw redirect({ to: "/login/select-organization" });
|
||||
}
|
||||
const orgId = data.subOrganizationId || data.organizationId;
|
||||
throw redirect({
|
||||
to: "/organizations/$orgId/projects",
|
||||
params: { orgId: data.organizationId }
|
||||
params: { orgId }
|
||||
});
|
||||
},
|
||||
component: AuthConsentWrapper
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
|
||||
import { OrgPermissionGuardBanner } from "@app/components/permissions/OrgPermissionCan";
|
||||
import { Button, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
@@ -84,8 +85,20 @@ export const AccessManagementPage = () => {
|
||||
<PageHeader
|
||||
scope={isSubOrganization ? "namespace" : "org"}
|
||||
title={`${isSubOrganization ? "Sub-Organization" : "Organization"} Access Control`}
|
||||
description="Manage fine-grained access for users, groups, roles, and machine identities within your organization resources."
|
||||
/>
|
||||
description={`Manage fine-grained access for users, groups, roles, and machine identities within your ${isSubOrganization ? "sub-" : ""}organization resources.`}
|
||||
>
|
||||
{isSubOrganization && (
|
||||
<Link
|
||||
to="/organizations/$orgId/access-management"
|
||||
params={{
|
||||
orgId: currentOrg.rootOrgId ?? ""
|
||||
}}
|
||||
className="flex items-center gap-x-1.5 text-xs whitespace-nowrap text-neutral hover:underline"
|
||||
>
|
||||
<InfoIcon size={12} /> Looking for root organization access control?
|
||||
</Link>
|
||||
)}
|
||||
</PageHeader>
|
||||
{!currentOrg.shouldUseNewPrivilegeSystem && (
|
||||
<div className="mt-4 mb-4 flex flex-col rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5">
|
||||
<div className="mb-1 flex items-center text-sm">
|
||||
|
||||
@@ -6,7 +6,12 @@ import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||
import { DocumentationLinkBadge } from "@app/components/v3";
|
||||
import { OrgPermissionGroupActions, OrgPermissionSubjects, useSubscription } from "@app/context";
|
||||
import {
|
||||
OrgPermissionGroupActions,
|
||||
OrgPermissionSubjects,
|
||||
useOrganization,
|
||||
useSubscription
|
||||
} from "@app/context";
|
||||
import { useDeleteGroup } from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
@@ -15,6 +20,7 @@ import { OrgGroupsTable } from "./OrgGroupsTable";
|
||||
|
||||
export const OrgGroupsSection = () => {
|
||||
const { subscription } = useSubscription();
|
||||
const { isSubOrganization } = useOrganization();
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteGroup();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
@@ -51,7 +57,9 @@ export const OrgGroupsSection = () => {
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-xl font-medium text-mineshaft-100">Organization Groups</p>
|
||||
<p className="text-xl font-medium text-mineshaft-100">
|
||||
{isSubOrganization ? "Sub-" : ""}Organization Groups
|
||||
</p>
|
||||
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/groups" />
|
||||
</div>
|
||||
<OrgPermissionCan I={OrgPermissionGroupActions.Create} a={OrgPermissionSubjects.Groups}>
|
||||
@@ -63,7 +71,7 @@ export const OrgGroupsSection = () => {
|
||||
onClick={() => handleAddGroupModal()}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Create Organization Group
|
||||
Create {isSubOrganization ? "Sub-" : ""}Organization Group
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
|
||||
@@ -71,7 +71,7 @@ enum GroupsOrderBy {
|
||||
|
||||
export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const { isPending, data: groups = [] } = useGetOrganizationGroups(orgId);
|
||||
const { mutateAsync: updateMutateAsync } = useUpdateGroup();
|
||||
@@ -159,7 +159,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search organization groups..."
|
||||
placeholder={`Search ${isSubOrganization ? "sub-" : ""}organization groups...`}
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
@@ -205,7 +205,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
</Th>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Organization Role
|
||||
{isSubOrganization ? "Sub-" : ""}Organization Role
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={`ml-2 ${orderBy === GroupsOrderBy.Role ? "" : "opacity-30"}`}
|
||||
@@ -389,8 +389,8 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
<EmptyState
|
||||
title={
|
||||
groups.length
|
||||
? "No organization groups match search..."
|
||||
: "No organization groups found"
|
||||
? `No ${isSubOrganization ? "sub-" : ""}organization groups match search...`
|
||||
: `No ${isSubOrganization ? "sub-" : ""}organization groups found`
|
||||
}
|
||||
icon={groups.length ? faSearch : faUsers}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { LinkIcon, PlusIcon } from "lucide-react";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button, DeleteActionModal, Modal, ModalContent } from "@app/components/v2";
|
||||
import { Button, DeleteActionModal, Modal, ModalContent, Tooltip } from "@app/components/v2";
|
||||
import { DocumentationLinkBadge } from "@app/components/v3";
|
||||
import {
|
||||
OrgPermissionIdentityActions,
|
||||
@@ -30,9 +30,8 @@ import { OrgIdentityLinkForm } from "./OrgIdentityLinkForm";
|
||||
import { OrgIdentityModal } from "./OrgIdentityModal";
|
||||
|
||||
enum IdentityWizardSteps {
|
||||
SelectAction = "select-action",
|
||||
LinkIdentity = "link-identity",
|
||||
OrganizationIdentity = "project-identity"
|
||||
CreateIdentity = "create-identity",
|
||||
LinkIdentity = "link-identity"
|
||||
}
|
||||
|
||||
export const IdentitySection = withPermission(
|
||||
@@ -41,7 +40,7 @@ export const IdentitySection = withPermission(
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const [wizardStep, setWizardStep] = useState(IdentityWizardSteps.SelectAction);
|
||||
const [wizardStep, setWizardStep] = useState(IdentityWizardSteps.CreateIdentity);
|
||||
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteOrgIdentity();
|
||||
const { mutateAsync: deleteTemplateMutateAsync } = useDeleteIdentityAuthTemplate();
|
||||
@@ -100,7 +99,7 @@ export const IdentitySection = withPermission(
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-1 items-center gap-x-2">
|
||||
<p className="text-xl font-medium text-mineshaft-100">
|
||||
Organization Machine Identities
|
||||
{isSubOrganization ? "Sub-" : ""}Organization Machine Identities
|
||||
</p>
|
||||
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/identities/machine-identities" />
|
||||
</div>
|
||||
@@ -124,7 +123,7 @@ export const IdentitySection = withPermission(
|
||||
}
|
||||
|
||||
if (!isSubOrganization) {
|
||||
setWizardStep(IdentityWizardSteps.OrganizationIdentity);
|
||||
setWizardStep(IdentityWizardSteps.CreateIdentity);
|
||||
}
|
||||
|
||||
handlePopUpOpen("identity");
|
||||
@@ -197,7 +196,7 @@ export const IdentitySection = withPermission(
|
||||
onOpenChange={(open) => {
|
||||
handlePopUpToggle("identity", open);
|
||||
if (!open) {
|
||||
setWizardStep(IdentityWizardSteps.SelectAction);
|
||||
setWizardStep(IdentityWizardSteps.CreateIdentity);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -214,80 +213,84 @@ export const IdentitySection = withPermission(
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{wizardStep === IdentityWizardSteps.SelectAction && (
|
||||
<motion.div
|
||||
key="select-type-step"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
>
|
||||
<div
|
||||
className="cursor-pointer rounded-md border border-mineshaft-600 p-4 transition-all hover:bg-mineshaft-700"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setWizardStep(IdentityWizardSteps.OrganizationIdentity)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setWizardStep(IdentityWizardSteps.OrganizationIdentity);
|
||||
}
|
||||
{isSubOrganization && (
|
||||
<div className="mb-4 flex items-center justify-center gap-x-2">
|
||||
<div className="flex w-3/4 gap-x-0.5 rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
setWizardStep(IdentityWizardSteps.CreateIdentity);
|
||||
}}
|
||||
size="xs"
|
||||
className={twMerge(
|
||||
"min-w-[2.4rem] flex-1 rounded border-none hover:bg-mineshaft-600",
|
||||
wizardStep === IdentityWizardSteps.CreateIdentity
|
||||
? "bg-mineshaft-500"
|
||||
: "bg-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<PlusIcon size="1rem" />
|
||||
<div>Create Machine Identity</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-mineshaft-300">
|
||||
Create a new machine identity specifically for this sub-organization. This
|
||||
machine identity will be managed at the sub-organization level.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-4 cursor-pointer rounded-md border border-mineshaft-600 p-4 transition-all hover:bg-mineshaft-700"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setWizardStep(IdentityWizardSteps.LinkIdentity)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setWizardStep(IdentityWizardSteps.LinkIdentity);
|
||||
}
|
||||
Create New
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
setWizardStep(IdentityWizardSteps.LinkIdentity);
|
||||
}}
|
||||
size="xs"
|
||||
className={twMerge(
|
||||
"min-w-[2.4rem] flex-1 rounded border-none hover:bg-mineshaft-600",
|
||||
wizardStep === IdentityWizardSteps.LinkIdentity
|
||||
? "bg-mineshaft-500"
|
||||
: "bg-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkIcon size="1rem" />
|
||||
<div>Assign Existing Machine Identity</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-mineshaft-300">
|
||||
Assign an existing machine identity from your parent organization. The machine
|
||||
identity will continue to be managed at its original scope.
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
{wizardStep === IdentityWizardSteps.OrganizationIdentity && (
|
||||
<motion.div
|
||||
key="identity-step"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
Assign Existing
|
||||
</Button>
|
||||
</div>
|
||||
<Tooltip
|
||||
className="max-w-sm"
|
||||
position="right"
|
||||
align="start"
|
||||
content={
|
||||
<>
|
||||
<p className="mb-2 text-mineshaft-300">
|
||||
You can add machine identities to your sub-organization in one of two ways:
|
||||
</p>
|
||||
<ul className="ml-3.5 flex list-disc flex-col gap-y-4">
|
||||
<li className="text-mineshaft-200">
|
||||
<strong className="font-medium text-mineshaft-100">Create New</strong> -
|
||||
Create a new machine identity specifically for this sub-organization. This
|
||||
machine identity will be managed at the sub-organization level.
|
||||
<p className="mt-2">
|
||||
This method is recommended for autonomous teams that need to manage
|
||||
machine identity authentication.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-medium text-mineshaft-100">
|
||||
Assign Existing
|
||||
</strong>{" "}
|
||||
Assign an existing machine identity from your parent organization. The
|
||||
machine identity will continue to be managed at its original scope.
|
||||
<p className="mt-2">
|
||||
This method is recommended for organizations that need to maintain
|
||||
centralized control.
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<OrgIdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
</motion.div>
|
||||
)}
|
||||
{wizardStep === IdentityWizardSteps.LinkIdentity && (
|
||||
<motion.div
|
||||
key="link-step"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
>
|
||||
<OrgIdentityLinkForm onClose={() => handlePopUpClose("identity")} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<InfoIcon size={16} className="text-mineshaft-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{wizardStep === IdentityWizardSteps.CreateIdentity && (
|
||||
<OrgIdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
)}
|
||||
{wizardStep === IdentityWizardSteps.LinkIdentity && (
|
||||
<OrgIdentityLinkForm onClose={() => handlePopUpClose("identity")} />
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<DeleteActionModal
|
||||
|
||||
@@ -200,7 +200,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
</DropdownSubMenuTrigger>
|
||||
<DropdownSubMenuContent className="max-h-80 thin-scrollbar overflow-y-auto rounded-l-none">
|
||||
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
|
||||
Filter Organization Machine Identities by Role
|
||||
Filter {isSubOrganization ? "Sub-" : ""}Organization Machine Identities by Role
|
||||
</DropdownMenuLabel>
|
||||
{roles?.map(({ id, slug, name }) => (
|
||||
<DropdownMenuItem
|
||||
@@ -229,7 +229,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search machine identities by name..."
|
||||
placeholder={`Search ${isSubOrganization ? "sub-organization" : "organization"} machine identities by name...`}
|
||||
/>
|
||||
</div>
|
||||
<TableContainer>
|
||||
@@ -258,7 +258,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
</Th>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Organization Role
|
||||
{isSubOrganization ? "Sub-" : ""}Organization Role
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={`ml-2 ${orderBy === OrgIdentityOrderBy.Role ? "" : "opacity-30"}`}
|
||||
@@ -304,7 +304,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
to: "/organizations/$orgId/identities/$identityId",
|
||||
params: {
|
||||
identityId: id,
|
||||
orgId
|
||||
orgId: currentOrg.id
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -455,8 +455,8 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
<EmptyState
|
||||
title={
|
||||
debouncedSearch.trim().length > 0 || filter.roles?.length > 0
|
||||
? "No machine identities match search filter"
|
||||
: "No machine identities have been created in this organization"
|
||||
? `No ${isSubOrganization ? "sub-" : ""}organization machine identities match search filter`
|
||||
: `No machine identities have been created in this ${isSubOrganization ? "sub-" : ""}organization`
|
||||
}
|
||||
icon={faServer}
|
||||
/>
|
||||
|
||||
@@ -205,7 +205,9 @@ export const OrgMembersSection = () => {
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-xl font-medium text-mineshaft-100">Organization Users</p>
|
||||
<p className="text-xl font-medium text-mineshaft-100">
|
||||
{isSubOrganization ? "Sub-" : ""}Organization Users
|
||||
</p>
|
||||
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/identities/user-identities" />
|
||||
</div>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Member}>
|
||||
@@ -242,7 +244,7 @@ export const OrgMembersSection = () => {
|
||||
isOpen={popUp.addMemberToSubOrg.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addMemberToSubOrg", isOpen)}
|
||||
>
|
||||
<ModalContent title="Add member from your organization">
|
||||
<ModalContent title="Add member from your organization" bodyClassName="overflow-visible">
|
||||
<AddSubOrgMemberModal onClose={() => handlePopUpClose("addMemberToSubOrg")} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -336,7 +336,7 @@ export const OrgMembersTable = ({
|
||||
</DropdownSubMenuTrigger>
|
||||
<DropdownSubMenuContent className="max-h-80 thin-scrollbar overflow-y-auto rounded-l-none">
|
||||
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
|
||||
Filter Organization Users by Role
|
||||
Filter {isSubOrganization ? "Sub-" : ""}Organization Users by Role
|
||||
</DropdownMenuLabel>
|
||||
{roles?.map(({ id, slug, name }) => (
|
||||
<DropdownMenuItem
|
||||
@@ -365,7 +365,7 @@ export const OrgMembersTable = ({
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search organization users..."
|
||||
placeholder={`Search ${isSubOrganization ? "sub-" : ""}organization users...`}
|
||||
/>
|
||||
</div>
|
||||
<TableContainer className="mt-4">
|
||||
@@ -434,7 +434,7 @@ export const OrgMembersTable = ({
|
||||
</Th>
|
||||
<Th className="w-1/3">
|
||||
<div className="flex items-center">
|
||||
Organization Role
|
||||
{isSubOrganization ? "Sub-" : ""}Organization Role
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={`ml-2 ${orderBy === OrgMembersOrderBy.Role ? "" : "opacity-30"}`}
|
||||
@@ -727,8 +727,8 @@ export const OrgMembersTable = ({
|
||||
<EmptyState
|
||||
title={
|
||||
members.length
|
||||
? "No organization users match search..."
|
||||
: "No organization users found"
|
||||
? `No ${isSubOrganization ? "sub-" : ""}organization users match search...`
|
||||
: `No ${isSubOrganization ? "sub-" : ""}organization users found`
|
||||
}
|
||||
icon={members.length ? faSearch : faUsers}
|
||||
/>
|
||||
|
||||
@@ -207,7 +207,7 @@ export const OrgRoleTable = () => {
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Organization Role
|
||||
Add {isSubOrganization ? "Sub-" : ""}Organization Role
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
@@ -216,7 +216,7 @@ export const OrgRoleTable = () => {
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search organization roles..."
|
||||
placeholder={`Search ${isSubOrganization ? "sub-" : ""}organization roles...`}
|
||||
className="flex-1"
|
||||
containerClassName="mb-4"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
@@ -6,7 +8,7 @@ import { useOrganization } from "@app/context";
|
||||
import { LogsSection } from "./components";
|
||||
|
||||
export const AuditLogsPage = () => {
|
||||
const { isSubOrganization } = useOrganization();
|
||||
const { isSubOrganization, currentOrg } = useOrganization();
|
||||
|
||||
return (
|
||||
<div className="h-full bg-bunker-800">
|
||||
@@ -21,7 +23,19 @@ export const AuditLogsPage = () => {
|
||||
scope={isSubOrganization ? "namespace" : "org"}
|
||||
title={`${isSubOrganization ? "Sub-Organization" : "Organization"} Audit Logs`}
|
||||
description="Audit logs for security and compliance teams to monitor information access."
|
||||
/>
|
||||
>
|
||||
{isSubOrganization && (
|
||||
<Link
|
||||
to="/organizations/$orgId/audit-logs"
|
||||
params={{
|
||||
orgId: currentOrg.rootOrgId ?? ""
|
||||
}}
|
||||
className="flex items-center gap-x-1.5 text-xs whitespace-nowrap text-neutral hover:underline"
|
||||
>
|
||||
<InfoIcon size={12} /> Looking for root organization audit logs?
|
||||
</Link>
|
||||
)}
|
||||
</PageHeader>
|
||||
<LogsSection pageView />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,7 +88,7 @@ const Page = () => {
|
||||
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
Organization Groups
|
||||
{isSubOrganization ? "Sub-" : ""}Organization Groups
|
||||
</Link>
|
||||
<PageHeader
|
||||
scope={isSubOrganization ? "namespace" : "org"}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
@@ -8,7 +10,7 @@ import { OrgTabGroup } from "./components";
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isSubOrganization } = useOrganization();
|
||||
const { isSubOrganization, currentOrg } = useOrganization();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -19,9 +21,21 @@ export const SettingsPage = () => {
|
||||
<div className="w-full max-w-8xl">
|
||||
<PageHeader
|
||||
scope={isSubOrganization ? "namespace" : "org"}
|
||||
description="Configure organization-wide settings"
|
||||
description={`Configure ${isSubOrganization ? "sub-" : ""}organization-wide settings`}
|
||||
title={isSubOrganization ? "Sub-Organization Settings" : "Organization Settings"}
|
||||
/>
|
||||
>
|
||||
{isSubOrganization && (
|
||||
<Link
|
||||
to="/organizations/$orgId/settings"
|
||||
params={{
|
||||
orgId: currentOrg.rootOrgId ?? ""
|
||||
}}
|
||||
className="flex items-center gap-x-1.5 text-xs whitespace-nowrap text-neutral hover:underline"
|
||||
>
|
||||
<InfoIcon size={12} /> Looking for root organization settings?
|
||||
</Link>
|
||||
)}
|
||||
</PageHeader>
|
||||
<OrgTabGroup />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@ const OrgConfigSection = ({
|
||||
resolver: zodResolver(orgConfigFormSchema)
|
||||
});
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
const { mutateAsync: setupOrgKmip } = useSetupOrgKmip(currentOrg.id);
|
||||
|
||||
const onFormSubmit = async (formData: TKmipOrgConfigForm) => {
|
||||
@@ -175,7 +175,10 @@ const OrgConfigSection = ({
|
||||
)}
|
||||
{!isKmipConfigLoading && !kmipConfig && (
|
||||
<div className="mt-2">
|
||||
<div>KMIP has not yet been configured for the organization.</div>
|
||||
<div>
|
||||
KMIP has not yet been configured for the {isSubOrganization ? "sub-" : ""}
|
||||
organization.
|
||||
</div>
|
||||
<Button
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
|
||||
@@ -47,7 +47,7 @@ export const OrgDeleteSection = () => {
|
||||
onClick={() => handlePopUpOpen("deleteOrg")}
|
||||
isDisabled={Boolean(!hasOrgRole(OrgMembershipRole.Admin))}
|
||||
>
|
||||
{`Delete ${currentOrg.subOrganization?.name ?? currentOrg?.name}`}
|
||||
{`Delete ${currentOrg?.name}`}
|
||||
</Button>
|
||||
</div>
|
||||
<DeleteActionModal
|
||||
|
||||
@@ -34,7 +34,7 @@ import { ExternalKmsItem } from "./ExternalKmsItem";
|
||||
|
||||
export const OrgEncryptionTab = withPermission(
|
||||
() => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
const { subscription } = useSubscription();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
@@ -90,7 +90,7 @@ export const OrgEncryptionTab = withPermission(
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<p className="mb-4 text-gray-400">
|
||||
Encrypt your organization's data with external KMS.
|
||||
Encrypt your {isSubOrganization ? "sub-" : ""}organization's data with external KMS.
|
||||
</p>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
|
||||
@@ -33,7 +33,7 @@ export const SubOrgNameChangeSection = (): JSX.Element => {
|
||||
const { handleSubmit, control } = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: currentOrg?.subOrganization?.name || ""
|
||||
name: currentOrg?.name || ""
|
||||
}
|
||||
});
|
||||
const { mutateAsync, isPending } = useUpdateSubOrganization();
|
||||
@@ -60,7 +60,7 @@ export const SubOrgNameChangeSection = (): JSX.Element => {
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="py-4">
|
||||
<div>
|
||||
<h2 className="text-md mb-2 text-mineshaft-100">Organization Name</h2>
|
||||
<h2 className="text-md mb-2 text-mineshaft-100">Sub-Organization Name</h2>
|
||||
<Controller
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
|
||||
@@ -112,7 +112,7 @@ const Page = withPermission(
|
||||
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
Organization Users
|
||||
{isSubOrganization ? "Sub-" : ""}Organization Users
|
||||
</Link>
|
||||
<PageHeader
|
||||
scope={isSubOrganization ? "namespace" : "org"}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ProjectGeneralTab } from "@app/pages/project/SettingsPage/components/Pr
|
||||
export const SettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
|
||||
@@ -31,7 +31,8 @@ export const SettingsPage = () => {
|
||||
}}
|
||||
className="flex items-center gap-x-1.5 text-xs whitespace-nowrap text-neutral hover:underline"
|
||||
>
|
||||
<InfoIcon size={12} /> Looking for organization settings?
|
||||
<InfoIcon size={12} /> Looking for {isSubOrganization ? "sub-" : ""}organization
|
||||
settings?
|
||||
</Link>
|
||||
</PageHeader>
|
||||
<Tabs orientation="vertical" defaultValue="tab-project-general">
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
|
||||
const Page = () => {
|
||||
const navigate = useNavigate();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
const { currentProject } = useProject();
|
||||
const selectedTab = useSearch({
|
||||
strict: false,
|
||||
@@ -54,7 +54,8 @@ const Page = () => {
|
||||
}}
|
||||
className="flex items-center gap-x-1.5 text-xs whitespace-nowrap text-neutral hover:underline"
|
||||
>
|
||||
<InfoIcon size={12} /> Looking for organization access control?
|
||||
<InfoIcon size={12} /> Looking for {isSubOrganization ? "sub-" : ""}organization access
|
||||
control?
|
||||
</Link>
|
||||
</PageHeader>
|
||||
<Tabs orientation="vertical" value={selectedTab} onValueChange={updateSelectedTab}>
|
||||
|
||||
@@ -3,12 +3,12 @@ import { Link } from "@tanstack/react-router";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useOrganization, useProject } from "@app/context";
|
||||
import { LogsSection } from "@app/pages/organization/AuditLogsPage/components";
|
||||
|
||||
export const AuditLogsPage = () => {
|
||||
const { currentProject } = useProject();
|
||||
|
||||
const { isSubOrganization } = useOrganization();
|
||||
return (
|
||||
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<Helmet>
|
||||
@@ -29,7 +29,8 @@ export const AuditLogsPage = () => {
|
||||
}}
|
||||
className="flex items-center gap-x-1.5 text-xs whitespace-nowrap text-neutral hover:underline"
|
||||
>
|
||||
<InfoIcon size={12} /> Looking for organization audit logs?
|
||||
<InfoIcon size={12} /> Looking for {isSubOrganization ? "sub-" : ""}organization audit
|
||||
logs?
|
||||
</Link>
|
||||
</PageHeader>
|
||||
<LogsSection pageView project={currentProject} />
|
||||
|
||||
@@ -86,7 +86,7 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
|
||||
setUserTablePreference("projectGroupMembersTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const { isSubOrganization, currentOrg } = useOrganization();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentProject } = useProject();
|
||||
|
||||
const { data: groupMemberships, isPending } = useListProjectGroupUsers({
|
||||
@@ -154,7 +154,7 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
|
||||
text: "User privilege assumption has started"
|
||||
});
|
||||
|
||||
const url = `${getProjectHomePage(currentProject.type, currentProject.environments)}${isSubOrganization ? `?subOrganization=${currentOrg.slug}` : ""}`;
|
||||
const url = getProjectHomePage(currentProject.type, currentProject.environments);
|
||||
window.location.assign(
|
||||
url.replace("$orgId", currentOrg.id).replace("$projectId", currentProject.id)
|
||||
);
|
||||
|
||||
@@ -63,7 +63,7 @@ const Page = () => {
|
||||
select: (el) => el.identityId as string
|
||||
});
|
||||
const { currentProject, projectId } = useProject();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
const { currentOrg } = useOrganization();
|
||||
|
||||
const { data: identityMembershipDetails, isPending: isMembershipDetailsLoading } =
|
||||
useGetProjectIdentityMembershipV2(projectId, identityId);
|
||||
@@ -71,8 +71,6 @@ const Page = () => {
|
||||
const { mutateAsync: removeIdentityMutateAsync } = useDeleteProjectIdentityMembership();
|
||||
|
||||
const isProjectIdentity = Boolean(identityMembershipDetails?.identity.projectId);
|
||||
const isNonScopedIdentity =
|
||||
!isProjectIdentity && currentOrg.id !== identityMembershipDetails?.identity?.orgId;
|
||||
|
||||
const {
|
||||
data: identity,
|
||||
@@ -108,7 +106,7 @@ const Page = () => {
|
||||
type: "success",
|
||||
text: "Machine identity privilege assumption has started"
|
||||
});
|
||||
const url = `${getProjectHomePage(currentProject.type, currentProject.environments)}${isSubOrganization && isNonScopedIdentity ? `?subOrganization=${currentOrg.slug}` : ""}`;
|
||||
const url = getProjectHomePage(currentProject.type, currentProject.environments);
|
||||
window.location.assign(
|
||||
url.replace("$orgId", currentOrg.id).replace("$projectId", currentProject.id)
|
||||
);
|
||||
|
||||
@@ -208,7 +208,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
|
||||
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
|
||||
isDisabled={isIdentityEditDisabled}
|
||||
className={twMerge(
|
||||
"w-full border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
|
||||
"bg-mineshaft-600 hover:bg-mineshaft-500 w-full border-none py-2.5 text-xs capitalize",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
@@ -226,7 +226,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
<div className="text-mineshaft-300 border-b border-b-gray-700 pb-2 text-sm">
|
||||
Configure Timed Access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
@@ -306,7 +306,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
|
||||
<div className="flex items-center space-x-4">
|
||||
{isDirty && (
|
||||
<Button
|
||||
className="mr-4 text-mineshaft-300"
|
||||
className="text-mineshaft-300 mr-4"
|
||||
variant="link"
|
||||
isDisabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
@@ -316,7 +316,11 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<AddPoliciesButton isDisabled={isDisabled} projectType={currentProject.type} />
|
||||
<AddPoliciesButton
|
||||
isDisabled={isDisabled}
|
||||
projectType={currentProject.type}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ export const Page = () => {
|
||||
strict: false,
|
||||
select: (el) => el.membershipId as string
|
||||
});
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentProject, projectId } = useProject();
|
||||
|
||||
const { data: membershipDetails, isPending: isMembershipDetailsLoading } =
|
||||
@@ -73,7 +73,7 @@ export const Page = () => {
|
||||
text: "User privilege assumption has started"
|
||||
});
|
||||
|
||||
const url = `${getProjectHomePage(currentProject.type, currentProject.environments)}${isSubOrganization ? `?subOrganization=${currentOrg.slug}` : ""}`;
|
||||
const url = getProjectHomePage(currentProject.type, currentProject.environments);
|
||||
window.location.assign(
|
||||
url.replace("$orgId", currentOrg.id).replace("$projectId", currentProject.id)
|
||||
);
|
||||
|
||||
@@ -216,7 +216,11 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<AddPoliciesButton isDisabled={isDisabled} projectType={currentProject.type} />
|
||||
<AddPoliciesButton
|
||||
isDisabled={isDisabled}
|
||||
projectType={currentProject.type}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,9 +21,10 @@ import { VaultPolicyImportModal } from "@app/pages/project/RoleDetailsBySlugPage
|
||||
type Props = {
|
||||
isDisabled?: boolean;
|
||||
projectType: ProjectType;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
|
||||
export const AddPoliciesButton = ({ isDisabled, projectType, projectId }: Props) => {
|
||||
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"addPolicy",
|
||||
"addPolicyOptions",
|
||||
@@ -109,6 +110,7 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<PolicySelectionModal
|
||||
projectId={projectId}
|
||||
type={projectType}
|
||||
isOpen={popUp.addPolicy.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addPolicy", isOpen)}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionSub, useProject } from "@app/context";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { useGetWorkspaceIntegrations } from "@app/hooks/api";
|
||||
import { ProjectType } from "@app/hooks/api/projects/types";
|
||||
|
||||
@@ -34,23 +34,25 @@ type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
type: ProjectType;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
onClose: () => void;
|
||||
|
||||
// note(daniel): we allow projectId to be undefined because we use this component for project templates, in which case no project ID will be present.
|
||||
projectId?: string;
|
||||
type: ProjectType;
|
||||
};
|
||||
|
||||
type TForm = { permissions: Record<ProjectPermissionSub, boolean> };
|
||||
|
||||
const Content = ({ onClose, type: projectType }: ContentProps) => {
|
||||
const Content = ({ onClose, projectId, type: projectType }: ContentProps) => {
|
||||
const rootForm = useFormContext<TFormSchema>();
|
||||
const [search, setSearch] = useState("");
|
||||
const { currentProject, projectId } = useProject();
|
||||
const isSecretManagerProject = currentProject.type === ProjectType.SecretManager;
|
||||
const { data: integrations = [] } = useGetWorkspaceIntegrations(projectId, {
|
||||
enabled: isSecretManagerProject,
|
||||
const isSecretManagerProject = projectType === ProjectType.SecretManager;
|
||||
const { data: integrations = [] } = useGetWorkspaceIntegrations(projectId ?? "", {
|
||||
enabled: Boolean(isSecretManagerProject && projectId),
|
||||
refetchInterval: false
|
||||
});
|
||||
|
||||
@@ -216,7 +218,7 @@ const Content = ({ onClose, type: projectType }: ContentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const PolicySelectionModal = ({ isOpen, onOpenChange, type }: Props) => {
|
||||
export const PolicySelectionModal = ({ isOpen, onOpenChange, type, projectId }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
@@ -224,7 +226,7 @@ export const PolicySelectionModal = ({ isOpen, onOpenChange, type }: Props) => {
|
||||
subTitle="Select one or more policies to add to this role."
|
||||
className="max-w-3xl"
|
||||
>
|
||||
<Content onClose={() => onOpenChange(false)} type={type} />
|
||||
<Content onClose={() => onOpenChange(false)} type={type} projectId={projectId} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -209,7 +209,11 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
|
||||
Save
|
||||
</Button>
|
||||
<div className="ml-2 border-l border-mineshaft-500 pl-4">
|
||||
<AddPoliciesButton isDisabled={isDisabled} projectType={currentProject.type} />
|
||||
<AddPoliciesButton
|
||||
isDisabled={isDisabled}
|
||||
projectType={currentProject.type}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRootRouteWithContext, Outlet, useSearch } from "@tanstack/react-router";
|
||||
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
|
||||
|
||||
import { NotificationContainer } from "@app/components/notifications";
|
||||
import { TooltipProvider } from "@app/components/v2";
|
||||
@@ -14,15 +13,6 @@ type TRouterContext = {
|
||||
};
|
||||
|
||||
const RootPage = () => {
|
||||
const subOrganization = useSearch({
|
||||
strict: false,
|
||||
select: (el) => el?.subOrganization
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
queryClient.invalidateQueries();
|
||||
}, [subOrganization]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Link } from "@tanstack/react-router";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
|
||||
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useOrganization, useProject } from "@app/context";
|
||||
import { ProjectType, ProjectVersion } from "@app/hooks/api/projects/types";
|
||||
import { ProjectGeneralTab } from "@app/pages/project/SettingsPage/components/ProjectGeneralTab";
|
||||
|
||||
@@ -15,6 +15,8 @@ import { WorkflowIntegrationTab } from "./components/WorkflowIntegrationSection"
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isSubOrganization } = useOrganization();
|
||||
|
||||
const { currentProject } = useProject();
|
||||
const tabs = [
|
||||
{ name: "General", key: "tab-project-general", Component: ProjectGeneralTab },
|
||||
@@ -55,7 +57,8 @@ export const SettingsPage = () => {
|
||||
}}
|
||||
className="flex items-center gap-x-1.5 text-xs whitespace-nowrap text-neutral hover:underline"
|
||||
>
|
||||
<InfoIcon size={12} /> Looking for organization settings?
|
||||
<InfoIcon size={12} /> Looking for {isSubOrganization ? "sub-" : ""}organization
|
||||
settings?
|
||||
</Link>
|
||||
</PageHeader>
|
||||
<Tabs orientation="vertical" defaultValue={tabs[0].key}>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ProjectScanningConfigTab } from "./components/ProjectScanningConfigTab"
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
|
||||
@@ -34,7 +34,8 @@ export const SettingsPage = () => {
|
||||
}}
|
||||
className="flex items-center gap-x-1.5 text-xs whitespace-nowrap text-neutral hover:underline"
|
||||
>
|
||||
<InfoIcon size={12} /> Looking for organization settings?
|
||||
<InfoIcon size={12} /> Looking for {isSubOrganization ? "sub-" : ""}organization
|
||||
settings?
|
||||
</Link>
|
||||
</PageHeader>
|
||||
<Tabs orientation="vertical" defaultValue="tab-project-general">
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ProjectSshTab } from "./components/ProjectSshTab";
|
||||
export const SettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
|
||||
@@ -34,7 +34,8 @@ export const SettingsPage = () => {
|
||||
}}
|
||||
className="flex items-center gap-x-1.5 text-xs whitespace-nowrap text-neutral hover:underline"
|
||||
>
|
||||
<InfoIcon size={12} /> Looking for organization settings?
|
||||
<InfoIcon size={12} /> Looking for {isSubOrganization ? "sub-" : ""}organization
|
||||
settings?
|
||||
</Link>
|
||||
</PageHeader>
|
||||
<Tabs orientation="vertical" defaultValue="tab-project-general">
|
||||
|
||||
Reference in New Issue
Block a user