feat: added root org identity link functionality

This commit is contained in:
=
2025-10-20 18:58:22 +05:30
parent 545ea4e27c
commit b7644c9294
26 changed files with 753 additions and 82 deletions
@@ -21,6 +21,7 @@ export const useOrganization = () => {
id: currentOrg?.subOrganization?.id || currentOrg?.id,
parentOrgId: currentOrg.id
},
isSubOrganization: Boolean(currentOrg.subOrganization)
isSubOrganization: Boolean(currentOrg.subOrganization),
isRootOrganization: !currentOrg.subOrganization
};
};
@@ -0,0 +1,2 @@
export { useCreateOrgIdentityMembership, useDeleteOrgIdentityMembership } from "./mutation";
export type { TCreateOrgIdentityMembershipDTO, TDeleteOrgIdentityMembershipDTO, TOrgIdentityMembership } from "./types";
@@ -0,0 +1,42 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import {
TCreateOrgIdentityMembershipDTO,
TDeleteOrgIdentityMembershipDTO,
TOrgIdentityMembership
} from "./types";
export const useCreateOrgIdentityMembership = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ identityId, roles }: TCreateOrgIdentityMembershipDTO) => {
const { data } = await apiRequest.post<{ identityMembership: TOrgIdentityMembership }>(
`/api/v1/organization/identity-memberships/${identityId}`,
{ roles }
);
return data.identityMembership;
},
onSuccess: () => {
// Invalidate relevant queries if needed
queryClient.invalidateQueries({ queryKey: ["organization"] });
}
});
};
export const useDeleteOrgIdentityMembership = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ identityId }: TDeleteOrgIdentityMembershipDTO) => {
const { data } = await apiRequest.delete<{ identityMembership: TOrgIdentityMembership }>(
`/api/v1/organization/identity-memberships/${identityId}`
);
return data.identityMembership;
},
onSuccess: () => {
// Invalidate relevant queries if needed
queryClient.invalidateQueries({ queryKey: ["organization"] });
}
});
};
@@ -0,0 +1,30 @@
import { TemporaryPermissionMode } from "@app/db/schemas";
export type TOrgIdentityMembership = {
id: string;
orgId: string;
identityId: string;
createdAt: string;
updatedAt: string;
};
export type TCreateOrgIdentityMembershipDTO = {
identityId: string;
roles: Array<
| {
role: string;
isTemporary?: false;
}
| {
role: string;
isTemporary: true;
temporaryMode: TemporaryPermissionMode;
temporaryRange: string;
temporaryAccessStartTime: string;
}
>;
};
export type TDeleteOrgIdentityMembershipDTO = {
identityId: string;
};
@@ -1,5 +1,6 @@
export {
useAddOrgPmtMethod,
useGetAvailableOrgIdentities,
useAddOrgTaxId,
useCreateCustomerPortalSession,
useCreateOrg,
@@ -42,7 +42,9 @@ 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) => ["organization", { orgId }]
getOrgById: (orgId: string) => ["organization", { orgId }],
getAvailableIdentities: () => ["available-identities"],
getAvailableUsers: () => ["available-users"]
};
export const fetchOrganizations = async () => {
@@ -574,3 +576,29 @@ export const useGetOrgIntegrationAuths = <TData = IntegrationAuth[],>(
select
});
};
export const useGetAvailableOrgIdentities = (enabled = true) =>
useQuery({
queryKey: organizationKeys.getAvailableIdentities(),
queryFn: async () => {
const { data } = await apiRequest.get<{ identities: { name: string; id: string }[] }>(
`/api/v1/organization/identities/available`
);
return data.identities;
},
enabled
});
export const useGetAvailableOrgUsers = (enabled = true) =>
useQuery({
queryKey: organizationKeys.getAvailableUsers(),
queryFn: async () => {
const { data } = await apiRequest.get<{
users: { username: string; id: string; firstName: string; lastName: string }[];
}>(`/api/v1/organization/users/available`);
return data.users;
},
enabled
});
@@ -0,0 +1,129 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FilterableSelect, FormControl } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useGetAvailableOrgIdentities, useGetOrgRoles } from "@app/hooks/api";
import { useCreateOrgIdentityMembership } from "@app/hooks/api/orgIdentityMembership";
const schema = z
.object({
identity: z.object({ name: z.string(), id: z.string() }),
role: z.object({ name: z.string(), slug: z.string() })
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
onClose: () => void;
};
export const IdentityLinkForm = ({ onClose }: Props) => {
const navigate = useNavigate();
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { data: roles } = useGetOrgRoles(orgId);
const { mutateAsync: createMutateAsync } = useCreateOrgIdentityMembership();
const { data: rootOrgIdentities, isPending: isRootOrgLoading } = useGetAvailableOrgIdentities();
const {
control,
handleSubmit,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {}
});
const onFormSubmit = async ({ identity, role }: FormData) => {
try {
await createMutateAsync({
identityId: identity.id,
roles: [{ role: role.slug, isTemporary: false }]
});
createNotification({
text: "Successfully linked identity",
type: "success"
});
navigate({
to: "/organization/identities/$identityId",
params: {
identityId: identity.id
}
});
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to link identity";
createNotification({
text,
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="identity"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl label="Identity" errorText={error?.message} isError={Boolean(error)}>
<FilterableSelect
value={value}
onChange={onChange}
placeholder="Select identity..."
options={rootOrgIdentities}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
isLoading={isRootOrgLoading}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="role"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Role"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<FilterableSelect
value={value}
onChange={onChange}
options={roles}
placeholder="Select role..."
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
menuPortalTarget={document.body}
/>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Link
</Button>
<Button colorSchema="secondary" variant="plain" onClick={() => onClose()}>
Cancel
</Button>
</div>
</form>
);
};
@@ -1,10 +1,15 @@
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
import {
faArrowUpRightFromSquare,
faBookOpen,
faLink,
faPlus
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { Button, DeleteActionModal, Modal, ModalContent } from "@app/components/v2";
import {
OrgPermissionIdentityActions,
OrgPermissionSubjects,
@@ -23,11 +28,12 @@ import { IdentityModal } from "./IdentityModal";
import { IdentityTable } from "./IdentityTable";
import { IdentityTokenAuthTokenModal } from "./IdentityTokenAuthTokenModal";
import { MachineAuthTemplateUsagesModal } from "./MachineAuthTemplateUsagesModal";
import { IdentityLinkForm } from "./IdentityLinkForm";
export const IdentitySection = withPermission(
() => {
const { subscription } = useSubscription();
const { currentOrg } = useOrganization();
const { currentOrg, isSubOrganization } = useOrganization();
const orgId = currentOrg?.id || "";
const { mutateAsync: deleteMutateAsync } = useDeleteIdentity();
@@ -43,7 +49,8 @@ export const IdentitySection = withPermission(
"createTemplate",
"editTemplate",
"deleteTemplate",
"viewUsages"
"viewUsages",
"linkIdentity"
] as const);
const isMoreIdentitiesAllowed = subscription?.identityLimit
@@ -105,8 +112,8 @@ export const IdentitySection = withPermission(
return (
<div>
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-1">
<div className="mb-4 flex w-full items-center gap-4">
<div className="flex flex-1 items-center gap-1">
<p className="text-xl font-medium text-mineshaft-100">Identities</p>
<a
href="https://infisical.com/docs/documentation/platform/identities/overview"
@@ -123,6 +130,26 @@ export const IdentitySection = withPermission(
</div>
</a>
</div>
{isSubOrganization && (
<OrgPermissionCan
I={OrgPermissionIdentityActions.Create}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<Button
variant="plain"
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faLink} />}
onClick={() => {
handlePopUpOpen("linkIdentity");
}}
isDisabled={!isAllowed}
>
Link Identity
</Button>
)}
</OrgPermissionCan>
)}
<OrgPermissionCan
I={OrgPermissionIdentityActions.Create}
a={OrgPermissionSubjects.Identity}
@@ -210,16 +237,17 @@ export const IdentitySection = withPermission(
?.name || ""
}
/>
{/* <IdentityAuthMethodModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/> */}
{/* <IdentityUniversalAuthClientSecretModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/> */}
<Modal
isOpen={popUp.linkIdentity.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("linkIdentity", isOpen)}
>
<ModalContent
title="Assign Existing Identity"
subTitle="Assign an existing identity from your organization or namespace to this project. The identity will continue to be managed at its original scope."
>
<IdentityLinkForm onClose={() => handlePopUpClose("linkIdentity")} />
</ModalContent>
</Modal>
<IdentityTokenAuthTokenModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen}
@@ -1,4 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { BillingPage } from "./BillingPage";
@@ -6,7 +6,14 @@ export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organization/billing"
)({
component: BillingPage,
beforeLoad: () => {
beforeLoad: ({ search }) => {
if (search.subOrganization) {
throw redirect({
to: "/organization/projects",
search
});
}
return {
breadcrumbs: [
{
@@ -14,25 +14,39 @@ import { OrgSecurityTab } from "../OrgSecurityTab";
import { OrgSsoTab } from "../OrgSsoTab";
import { OrgWorkflowIntegrationTab } from "../OrgWorkflowIntegrationTab";
import { ProjectTemplatesTab } from "../ProjectTemplatesTab";
import { useOrganization } from "@app/context";
export const OrgTabGroup = () => {
const search = useSearch({
from: ROUTE_PATHS.Organization.SettingsPage.id
});
const { isSubOrganization } = useOrganization();
const tabs = [
{ name: "General", key: "tab-org-general", component: OrgGeneralTab },
{
name: "SSO",
key: "sso-settings",
component: OrgSsoTab
component: OrgSsoTab,
isHidden: isSubOrganization
},
{
name: "Provisioning",
key: "provisioning-settings",
component: OrgProvisioningTab
component: OrgProvisioningTab,
isHidden: isSubOrganization
},
{
name: "Security",
key: "tab-org-security",
component: OrgSecurityTab,
isHidden: isSubOrganization
},
{
name: "Encryption",
key: "tab-org-encryption",
component: OrgEncryptionTab
},
{ name: "Security", key: "tab-org-security", component: OrgSecurityTab },
{ name: "Encryption", key: "tab-org-encryption", component: OrgEncryptionTab },
{
name: "Workflow Integrations",
key: "workflow-integrations",
@@ -57,17 +71,21 @@ export const OrgTabGroup = () => {
return (
<Tabs orientation="vertical" value={selectedTab} onValueChange={setSelectedTab}>
<TabList>
{tabs.map((tab) => (
<Tab variant="org" value={tab.key} key={tab.key}>
{tab.name}
</Tab>
))}
{tabs
.filter((el) => !el.isHidden)
.map((tab) => (
<Tab variant="org" value={tab.key} key={tab.key}>
{tab.name}
</Tab>
))}
</TabList>
{tabs.map(({ key, component: Component }) => (
<TabPanel value={key} key={`tab-panel-${key}`}>
<Component />
</TabPanel>
))}
{tabs
.filter((el) => !el.isHidden)
.map(({ key, component: Component }) => (
<TabPanel value={key} key={`tab-panel-${key}`}>
<Component />
</TabPanel>
))}
</Tabs>
);
};