feat: resolved breaking dev

This commit is contained in:
=
2024-12-16 23:07:31 +05:30
parent bf8df14b01
commit 701eb7cfc6
205 changed files with 25019 additions and 99 deletions

View File

@@ -0,0 +1,115 @@
import { FC } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
import { useCreateOrg, useSelectOrganization } from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { useNavigate } from "@tanstack/react-router";
const schema = z
.object({
name: z.string().nonempty({ message: "Name is required" })
})
.required();
export type FormData = z.infer<typeof schema>;
interface CreateOrgModalProps {
isOpen: boolean;
onClose: () => void;
}
export const CreateOrgModal: FC<CreateOrgModalProps> = ({ isOpen, onClose }) => {
const navigate = useNavigate();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: ""
}
});
const { mutateAsync: createOrg } = useCreateOrg({
invalidate: false
});
const { mutateAsync: selectOrg } = useSelectOrganization();
const onFormSubmit = async ({ name }: FormData) => {
try {
const organization = await createOrg({
name
});
await selectOrg({
organizationId: organization.id
});
createNotification({
text: "Successfully created organization",
type: "success"
});
navigate({
to: `/organization/$organizationId/${ProjectType.SecretManager}` as const,
params: {
organizationId: organization.id
}
});
localStorage.setItem("orgData.id", organization.id);
reset();
onClose();
} catch (err) {
console.error(err);
createNotification({
text: "Failed to created organization",
type: "error"
});
}
};
return (
<Modal isOpen={isOpen}>
<ModalContent
title="Create Organization"
subTitle="Looks like you're not part of any organizations. Create one to start using Infisical"
>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="Acme Corp" />
</FormControl>
)}
/>
<div className="flex w-full gap-4">
<Button
className=""
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create
</Button>
<Button className="" size="sm" variant="outline_bg" type="button" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1 @@
export { CreateOrgModal } from "./CreateOrgModal";

View File

@@ -0,0 +1,10 @@
export {
useCreateNewInstallationSession,
useLinkGitAppInstallationWithOrg,
useUpdateRiskStatus
} from "./mutation";
export {
secretScanningQueryKeys,
useGetSecretScanningInstallationStatus,
useGetSecretScanningRisks
} from "./queries";

View File

@@ -0,0 +1,45 @@
import { useMutation } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { RiskStatus, TGitAppOrg, TSecretScanningGitRisks } from "./types";
export const useCreateNewInstallationSession = () => {
return useMutation<{ sessionId: string }, object, { organizationId: string }>({
mutationFn: async (opt) => {
const { data } = await apiRequest.post(
"/api/v1/secret-scanning/create-installation-session/organization",
opt
);
return data;
}
});
};
export const useUpdateRiskStatus = () => {
return useMutation<
TSecretScanningGitRisks,
object,
{ organizationId: string; riskId: string; status: RiskStatus }
>({
mutationFn: async (opt) => {
const { data } = await apiRequest.post<TSecretScanningGitRisks>(
`/api/v1/secret-scanning/organization/${opt.organizationId}/risks/${opt.riskId}/status`,
{ status: opt.status }
);
return data;
}
});
};
export const useLinkGitAppInstallationWithOrg = () => {
return useMutation<TGitAppOrg, object, { sessionId: string; installationId: string }>({
mutationFn: async (opt) => {
const { data } = await apiRequest.post<TGitAppOrg>(
"/api/v1/secret-scanning/link-installation",
opt
);
return data;
}
});
};

View File

@@ -0,0 +1,34 @@
import { apiRequest } from "@app/config/request";
import { useQuery } from "@tanstack/react-query";
import { TSecretScanningGitRisks } from "./types";
export const secretScanningQueryKeys = {
getInstallationStatus: (orgId: string) => ["secret-scanning-installation-status", { orgId }],
getRisksByOrganizatio: (orgId: string) => ["secret-scanning-risks", { orgId }]
};
const fetchSecretScanningInstallationStatus = async (organizationId: string) => {
const { data } = await apiRequest.get<{ appInstallationCompleted: boolean }>(
`/api/v1/secret-scanning/installation-status/organization/${organizationId}`
);
return data;
};
export const useGetSecretScanningInstallationStatus = (orgId: string) =>
useQuery({
queryKey: secretScanningQueryKeys.getInstallationStatus(orgId),
queryFn: () => fetchSecretScanningInstallationStatus(orgId)
});
const fetchSecretScanningRisksByOrgId = async (oranizationId: string) => {
const { data } = await apiRequest.get<TSecretScanningGitRisks[]>(
`/api/v1/secret-scanning/organization/${oranizationId}/risks`
);
return data;
};
export const useGetSecretScanningRisks = (orgId: string) =>
useQuery({
queryKey: secretScanningQueryKeys.getRisksByOrganizatio(orgId),
queryFn: () => fetchSecretScanningRisksByOrgId(orgId)
});

View File

@@ -0,0 +1,51 @@
export enum RiskStatus {
RESOLVED_FALSE_POSITIVE = "RESOLVED_FALSE_POSITIVE",
RESOLVED_REVOKED = "RESOLVED_REVOKED",
RESOLVED_NOT_REVOKED = "RESOLVED_NOT_REVOKED",
UNRESOLVED = "UNRESOLVED"
}
export type TSecretScanningGitRisks = {
id: string;
description: string;
startLine: string;
endLine: string;
startColumn: string;
endColumn: string;
match: string;
secret: string;
file: string;
symlinkFile: string;
commit: string;
entropy: string;
author: string;
email: string;
date: string;
message: string;
tags: string[];
ruleID: string;
fingerprint: string;
status: string;
isFalsePositive: boolean; // New field for marking risks as false positives
isResolved: boolean; // New field for marking risks as resolved
riskOwner: string | null; // New field for setting a risk owner (nullable string)
installationId: string;
repositoryId: string;
repositoryLink: string;
repositoryFullName: string;
pusher: {
name: string;
email: string;
};
createdAt: string;
orgId: string;
};
export type TGitAppOrg = {
id: string;
installationId: string;
userId: string;
orgId: string;
createdAt: Date;
updatedAt: Date;
};

View File

@@ -5,6 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet } from "@tanstack/react-router";
import { Mfa } from "@app/components/auth/Mfa";
import { CreateOrgModal } from "@app/components/organization/CreateOrgModal";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Menu, MenuItem } from "@app/components/v2";
import { useOrganization, useUser } from "@app/context";
@@ -189,12 +190,10 @@ export const OrganizationLayout = () => {
<SidebarFooter />
</nav>
</aside>
{
// <CreateOrgModal
// isOpen={popUp?.createOrg?.isOpen}
// onClose={() => handlePopUpToggle("createOrg", false)}
// />
}
<CreateOrgModal
isOpen={popUp?.createOrg?.isOpen}
onClose={() => handlePopUpToggle("createOrg", false)}
/>
<main className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 dark:[color-scheme:dark]">
<Outlet />
</main>

View File

@@ -35,8 +35,22 @@ import { Route as authenticatePersonalSettingsIndexImport } from './routes/_auth
import { Route as RestrictloginsignupLoginProviderSuccessImport } from './routes/_restrict_login_signup/login/provider/success'
import { Route as RestrictloginsignupLoginProviderErrorImport } from './routes/_restrict_login_signup/login/provider/error'
import { Route as authenticateOrgdetailsOrganizationIndexImport } from './routes/_authenticate/_org_details/organization/index'
import { Route as authenticateOrgdetailsOrganizationOrganizationIdIndexImport } from './routes/_authenticate/_org_details/organization/$organizationId/index'
import { Route as authenticateOrgdetailsOrganizationOrganizationIdSecretManagerImport } from './routes/_authenticate/_org_details/organization/$organizationId/secret-manager'
import { Route as authenticateOrgdetailsOrganizationNoneIndexImport } from './routes/_authenticate/_org_details/organization/none/index'
import { Route as authenticateOrgdetailsOrganizationOrganizationIdSettingsIndexImport } from './routes/_authenticate/_org_details/organization/$organizationId/settings/index'
import { Route as authenticateOrgdetailsOrganizationOrganizationIdSecretSharingIndexImport } from './routes/_authenticate/_org_details/organization/$organizationId/secret-sharing/index'
import { Route as authenticateOrgdetailsOrganizationOrganizationIdSecretScanningIndexImport } from './routes/_authenticate/_org_details/organization/$organizationId/secret-scanning/index'
import { Route as authenticateOrgdetailsOrganizationOrganizationIdOverviewIndexImport } from './routes/_authenticate/_org_details/organization/$organizationId/overview/index'
import { Route as authenticateOrgdetailsOrganizationOrganizationIdMembersIndexImport } from './routes/_authenticate/_org_details/organization/$organizationId/members/index'
import { Route as authenticateOrgdetailsOrganizationOrganizationIdBillingIndexImport } from './routes/_authenticate/_org_details/organization/$organizationId/billing/index'
import { Route as authenticateOrgdetailsOrganizationOrganizationIdAuditLogsIndexImport } from './routes/_authenticate/_org_details/organization/$organizationId/audit-logs/index'
import { Route as authenticateOrgdetailsOrganizationOrganizationIdAdminIndexImport } from './routes/_authenticate/_org_details/organization/$organizationId/admin/index'
import { Route as authenticateOrgdetailsOrganizationOrganizationIdSecretManagerOverviewImport } from './routes/_authenticate/_org_details/organization/$organizationId/secret-manager/overview'
import { Route as authenticateOrgdetailsOrganizationOrganizationIdKmsOverviewImport } from './routes/_authenticate/_org_details/organization/$organizationId/kms/overview'
import { Route as authenticateOrgdetailsOrganizationOrganizationIdCertManagerOverviewImport } from './routes/_authenticate/_org_details/organization/$organizationId/cert-manager/overview'
import { Route as authenticateOrgdetailsOrganizationOrganizationIdRolesRoleIdIndexImport } from './routes/_authenticate/_org_details/organization/$organizationId/roles/$roleId/index'
import { Route as authenticateOrgdetailsOrganizationOrganizationIdMembershipsMembershipIdIndexImport } from './routes/_authenticate/_org_details/organization/$organizationId/memberships/$membershipId/index'
import { Route as authenticateOrgdetailsOrganizationOrganizationIdIdentitiesIdentityIdIndexImport } from './routes/_authenticate/_org_details/organization/$organizationId/identities/$identityId/index'
import { Route as authenticateOrgdetailsOrganizationOrganizationIdGroupsGroupIdIndexImport } from './routes/_authenticate/_org_details/organization/$organizationId/groups/$groupId/index'
// Create Virtual Routes
@@ -197,20 +211,134 @@ const authenticateOrgdetailsOrganizationIndexRoute =
getParentRoute: () => authenticateOrgdetailsLayoutRoute,
} as any)
const authenticateOrgdetailsOrganizationOrganizationIdIndexRoute =
authenticateOrgdetailsOrganizationOrganizationIdIndexImport.update({
id: '/organization/$organizationId/',
path: '/organization/$organizationId/',
const authenticateOrgdetailsOrganizationNoneIndexRoute =
authenticateOrgdetailsOrganizationNoneIndexImport.update({
id: '/organization/none/',
path: '/organization/none/',
getParentRoute: () => authenticateOrgdetailsLayoutRoute,
} as any)
const authenticateOrgdetailsOrganizationOrganizationIdSecretManagerRoute =
authenticateOrgdetailsOrganizationOrganizationIdSecretManagerImport.update({
id: '/organization/$organizationId/secret-manager',
path: '/organization/$organizationId/secret-manager',
const authenticateOrgdetailsOrganizationOrganizationIdSettingsIndexRoute =
authenticateOrgdetailsOrganizationOrganizationIdSettingsIndexImport.update({
id: '/organization/$organizationId/settings/',
path: '/organization/$organizationId/settings/',
getParentRoute: () => authenticateOrgdetailsLayoutRoute,
} as any)
const authenticateOrgdetailsOrganizationOrganizationIdSecretSharingIndexRoute =
authenticateOrgdetailsOrganizationOrganizationIdSecretSharingIndexImport.update(
{
id: '/organization/$organizationId/secret-sharing/',
path: '/organization/$organizationId/secret-sharing/',
getParentRoute: () => authenticateOrgdetailsLayoutRoute,
} as any,
)
const authenticateOrgdetailsOrganizationOrganizationIdSecretScanningIndexRoute =
authenticateOrgdetailsOrganizationOrganizationIdSecretScanningIndexImport.update(
{
id: '/organization/$organizationId/secret-scanning/',
path: '/organization/$organizationId/secret-scanning/',
getParentRoute: () => authenticateOrgdetailsLayoutRoute,
} as any,
)
const authenticateOrgdetailsOrganizationOrganizationIdOverviewIndexRoute =
authenticateOrgdetailsOrganizationOrganizationIdOverviewIndexImport.update({
id: '/organization/$organizationId/overview/',
path: '/organization/$organizationId/overview/',
getParentRoute: () => authenticateOrgdetailsLayoutRoute,
} as any)
const authenticateOrgdetailsOrganizationOrganizationIdMembersIndexRoute =
authenticateOrgdetailsOrganizationOrganizationIdMembersIndexImport.update({
id: '/organization/$organizationId/members/',
path: '/organization/$organizationId/members/',
getParentRoute: () => authenticateOrgdetailsLayoutRoute,
} as any)
const authenticateOrgdetailsOrganizationOrganizationIdBillingIndexRoute =
authenticateOrgdetailsOrganizationOrganizationIdBillingIndexImport.update({
id: '/organization/$organizationId/billing/',
path: '/organization/$organizationId/billing/',
getParentRoute: () => authenticateOrgdetailsLayoutRoute,
} as any)
const authenticateOrgdetailsOrganizationOrganizationIdAuditLogsIndexRoute =
authenticateOrgdetailsOrganizationOrganizationIdAuditLogsIndexImport.update({
id: '/organization/$organizationId/audit-logs/',
path: '/organization/$organizationId/audit-logs/',
getParentRoute: () => authenticateOrgdetailsLayoutRoute,
} as any)
const authenticateOrgdetailsOrganizationOrganizationIdAdminIndexRoute =
authenticateOrgdetailsOrganizationOrganizationIdAdminIndexImport.update({
id: '/organization/$organizationId/admin/',
path: '/organization/$organizationId/admin/',
getParentRoute: () => authenticateOrgdetailsLayoutRoute,
} as any)
const authenticateOrgdetailsOrganizationOrganizationIdSecretManagerOverviewRoute =
authenticateOrgdetailsOrganizationOrganizationIdSecretManagerOverviewImport.update(
{
id: '/organization/$organizationId/secret-manager/overview',
path: '/organization/$organizationId/secret-manager/overview',
getParentRoute: () => authenticateOrgdetailsLayoutRoute,
} as any,
)
const authenticateOrgdetailsOrganizationOrganizationIdKmsOverviewRoute =
authenticateOrgdetailsOrganizationOrganizationIdKmsOverviewImport.update({
id: '/organization/$organizationId/kms/overview',
path: '/organization/$organizationId/kms/overview',
getParentRoute: () => authenticateOrgdetailsLayoutRoute,
} as any)
const authenticateOrgdetailsOrganizationOrganizationIdCertManagerOverviewRoute =
authenticateOrgdetailsOrganizationOrganizationIdCertManagerOverviewImport.update(
{
id: '/organization/$organizationId/cert-manager/overview',
path: '/organization/$organizationId/cert-manager/overview',
getParentRoute: () => authenticateOrgdetailsLayoutRoute,
} as any,
)
const authenticateOrgdetailsOrganizationOrganizationIdRolesRoleIdIndexRoute =
authenticateOrgdetailsOrganizationOrganizationIdRolesRoleIdIndexImport.update(
{
id: '/organization/$organizationId/roles/$roleId/',
path: '/organization/$organizationId/roles/$roleId/',
getParentRoute: () => authenticateOrgdetailsLayoutRoute,
} as any,
)
const authenticateOrgdetailsOrganizationOrganizationIdMembershipsMembershipIdIndexRoute =
authenticateOrgdetailsOrganizationOrganizationIdMembershipsMembershipIdIndexImport.update(
{
id: '/organization/$organizationId/memberships/$membershipId/',
path: '/organization/$organizationId/memberships/$membershipId/',
getParentRoute: () => authenticateOrgdetailsLayoutRoute,
} as any,
)
const authenticateOrgdetailsOrganizationOrganizationIdIdentitiesIdentityIdIndexRoute =
authenticateOrgdetailsOrganizationOrganizationIdIdentitiesIdentityIdIndexImport.update(
{
id: '/organization/$organizationId/identities/$identityId/',
path: '/organization/$organizationId/identities/$identityId/',
getParentRoute: () => authenticateOrgdetailsLayoutRoute,
} as any,
)
const authenticateOrgdetailsOrganizationOrganizationIdGroupsGroupIdIndexRoute =
authenticateOrgdetailsOrganizationOrganizationIdGroupsGroupIdIndexImport.update(
{
id: '/organization/$organizationId/groups/$groupId/',
path: '/organization/$organizationId/groups/$groupId/',
getParentRoute: () => authenticateOrgdetailsLayoutRoute,
} as any,
)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
@@ -376,18 +504,116 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof authenticateOrgdetailsOrganizationIndexImport
parentRoute: typeof authenticateOrgdetailsLayoutImport
}
'/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-manager': {
id: '/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-manager'
path: '/organization/$organizationId/secret-manager'
fullPath: '/organization/$organizationId/secret-manager'
preLoaderRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdSecretManagerImport
'/_authenticate/_org_details/_org-layout/organization/none/': {
id: '/_authenticate/_org_details/_org-layout/organization/none/'
path: '/organization/none'
fullPath: '/organization/none'
preLoaderRoute: typeof authenticateOrgdetailsOrganizationNoneIndexImport
parentRoute: typeof authenticateOrgdetailsLayoutImport
}
'/_authenticate/_org_details/_org-layout/organization/$organizationId/': {
id: '/_authenticate/_org_details/_org-layout/organization/$organizationId/'
path: '/organization/$organizationId'
fullPath: '/organization/$organizationId'
preLoaderRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdIndexImport
'/_authenticate/_org_details/_org-layout/organization/$organizationId/cert-manager/overview': {
id: '/_authenticate/_org_details/_org-layout/organization/$organizationId/cert-manager/overview'
path: '/organization/$organizationId/cert-manager/overview'
fullPath: '/organization/$organizationId/cert-manager/overview'
preLoaderRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdCertManagerOverviewImport
parentRoute: typeof authenticateOrgdetailsLayoutImport
}
'/_authenticate/_org_details/_org-layout/organization/$organizationId/kms/overview': {
id: '/_authenticate/_org_details/_org-layout/organization/$organizationId/kms/overview'
path: '/organization/$organizationId/kms/overview'
fullPath: '/organization/$organizationId/kms/overview'
preLoaderRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdKmsOverviewImport
parentRoute: typeof authenticateOrgdetailsLayoutImport
}
'/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-manager/overview': {
id: '/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-manager/overview'
path: '/organization/$organizationId/secret-manager/overview'
fullPath: '/organization/$organizationId/secret-manager/overview'
preLoaderRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdSecretManagerOverviewImport
parentRoute: typeof authenticateOrgdetailsLayoutImport
}
'/_authenticate/_org_details/_org-layout/organization/$organizationId/admin/': {
id: '/_authenticate/_org_details/_org-layout/organization/$organizationId/admin/'
path: '/organization/$organizationId/admin'
fullPath: '/organization/$organizationId/admin'
preLoaderRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdAdminIndexImport
parentRoute: typeof authenticateOrgdetailsLayoutImport
}
'/_authenticate/_org_details/_org-layout/organization/$organizationId/audit-logs/': {
id: '/_authenticate/_org_details/_org-layout/organization/$organizationId/audit-logs/'
path: '/organization/$organizationId/audit-logs'
fullPath: '/organization/$organizationId/audit-logs'
preLoaderRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdAuditLogsIndexImport
parentRoute: typeof authenticateOrgdetailsLayoutImport
}
'/_authenticate/_org_details/_org-layout/organization/$organizationId/billing/': {
id: '/_authenticate/_org_details/_org-layout/organization/$organizationId/billing/'
path: '/organization/$organizationId/billing'
fullPath: '/organization/$organizationId/billing'
preLoaderRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdBillingIndexImport
parentRoute: typeof authenticateOrgdetailsLayoutImport
}
'/_authenticate/_org_details/_org-layout/organization/$organizationId/members/': {
id: '/_authenticate/_org_details/_org-layout/organization/$organizationId/members/'
path: '/organization/$organizationId/members'
fullPath: '/organization/$organizationId/members'
preLoaderRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdMembersIndexImport
parentRoute: typeof authenticateOrgdetailsLayoutImport
}
'/_authenticate/_org_details/_org-layout/organization/$organizationId/overview/': {
id: '/_authenticate/_org_details/_org-layout/organization/$organizationId/overview/'
path: '/organization/$organizationId/overview'
fullPath: '/organization/$organizationId/overview'
preLoaderRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdOverviewIndexImport
parentRoute: typeof authenticateOrgdetailsLayoutImport
}
'/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-scanning/': {
id: '/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-scanning/'
path: '/organization/$organizationId/secret-scanning'
fullPath: '/organization/$organizationId/secret-scanning'
preLoaderRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdSecretScanningIndexImport
parentRoute: typeof authenticateOrgdetailsLayoutImport
}
'/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-sharing/': {
id: '/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-sharing/'
path: '/organization/$organizationId/secret-sharing'
fullPath: '/organization/$organizationId/secret-sharing'
preLoaderRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdSecretSharingIndexImport
parentRoute: typeof authenticateOrgdetailsLayoutImport
}
'/_authenticate/_org_details/_org-layout/organization/$organizationId/settings/': {
id: '/_authenticate/_org_details/_org-layout/organization/$organizationId/settings/'
path: '/organization/$organizationId/settings'
fullPath: '/organization/$organizationId/settings'
preLoaderRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdSettingsIndexImport
parentRoute: typeof authenticateOrgdetailsLayoutImport
}
'/_authenticate/_org_details/_org-layout/organization/$organizationId/groups/$groupId/': {
id: '/_authenticate/_org_details/_org-layout/organization/$organizationId/groups/$groupId/'
path: '/organization/$organizationId/groups/$groupId'
fullPath: '/organization/$organizationId/groups/$groupId'
preLoaderRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdGroupsGroupIdIndexImport
parentRoute: typeof authenticateOrgdetailsLayoutImport
}
'/_authenticate/_org_details/_org-layout/organization/$organizationId/identities/$identityId/': {
id: '/_authenticate/_org_details/_org-layout/organization/$organizationId/identities/$identityId/'
path: '/organization/$organizationId/identities/$identityId'
fullPath: '/organization/$organizationId/identities/$identityId'
preLoaderRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdIdentitiesIdentityIdIndexImport
parentRoute: typeof authenticateOrgdetailsLayoutImport
}
'/_authenticate/_org_details/_org-layout/organization/$organizationId/memberships/$membershipId/': {
id: '/_authenticate/_org_details/_org-layout/organization/$organizationId/memberships/$membershipId/'
path: '/organization/$organizationId/memberships/$membershipId'
fullPath: '/organization/$organizationId/memberships/$membershipId'
preLoaderRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdMembershipsMembershipIdIndexImport
parentRoute: typeof authenticateOrgdetailsLayoutImport
}
'/_authenticate/_org_details/_org-layout/organization/$organizationId/roles/$roleId/': {
id: '/_authenticate/_org_details/_org-layout/organization/$organizationId/roles/$roleId/'
path: '/organization/$organizationId/roles/$roleId'
fullPath: '/organization/$organizationId/roles/$roleId'
preLoaderRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdRolesRoleIdIndexImport
parentRoute: typeof authenticateOrgdetailsLayoutImport
}
}
@@ -397,18 +623,60 @@ declare module '@tanstack/react-router' {
interface authenticateOrgdetailsLayoutRouteChildren {
authenticateOrgdetailsOrganizationIndexRoute: typeof authenticateOrgdetailsOrganizationIndexRoute
authenticateOrgdetailsOrganizationOrganizationIdSecretManagerRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdSecretManagerRoute
authenticateOrgdetailsOrganizationOrganizationIdIndexRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdIndexRoute
authenticateOrgdetailsOrganizationNoneIndexRoute: typeof authenticateOrgdetailsOrganizationNoneIndexRoute
authenticateOrgdetailsOrganizationOrganizationIdCertManagerOverviewRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdCertManagerOverviewRoute
authenticateOrgdetailsOrganizationOrganizationIdKmsOverviewRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdKmsOverviewRoute
authenticateOrgdetailsOrganizationOrganizationIdSecretManagerOverviewRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdSecretManagerOverviewRoute
authenticateOrgdetailsOrganizationOrganizationIdAdminIndexRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdAdminIndexRoute
authenticateOrgdetailsOrganizationOrganizationIdAuditLogsIndexRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdAuditLogsIndexRoute
authenticateOrgdetailsOrganizationOrganizationIdBillingIndexRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdBillingIndexRoute
authenticateOrgdetailsOrganizationOrganizationIdMembersIndexRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdMembersIndexRoute
authenticateOrgdetailsOrganizationOrganizationIdOverviewIndexRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdOverviewIndexRoute
authenticateOrgdetailsOrganizationOrganizationIdSecretScanningIndexRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdSecretScanningIndexRoute
authenticateOrgdetailsOrganizationOrganizationIdSecretSharingIndexRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdSecretSharingIndexRoute
authenticateOrgdetailsOrganizationOrganizationIdSettingsIndexRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdSettingsIndexRoute
authenticateOrgdetailsOrganizationOrganizationIdGroupsGroupIdIndexRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdGroupsGroupIdIndexRoute
authenticateOrgdetailsOrganizationOrganizationIdIdentitiesIdentityIdIndexRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdIdentitiesIdentityIdIndexRoute
authenticateOrgdetailsOrganizationOrganizationIdMembershipsMembershipIdIndexRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdMembershipsMembershipIdIndexRoute
authenticateOrgdetailsOrganizationOrganizationIdRolesRoleIdIndexRoute: typeof authenticateOrgdetailsOrganizationOrganizationIdRolesRoleIdIndexRoute
}
const authenticateOrgdetailsLayoutRouteChildren: authenticateOrgdetailsLayoutRouteChildren =
{
authenticateOrgdetailsOrganizationIndexRoute:
authenticateOrgdetailsOrganizationIndexRoute,
authenticateOrgdetailsOrganizationOrganizationIdSecretManagerRoute:
authenticateOrgdetailsOrganizationOrganizationIdSecretManagerRoute,
authenticateOrgdetailsOrganizationOrganizationIdIndexRoute:
authenticateOrgdetailsOrganizationOrganizationIdIndexRoute,
authenticateOrgdetailsOrganizationNoneIndexRoute:
authenticateOrgdetailsOrganizationNoneIndexRoute,
authenticateOrgdetailsOrganizationOrganizationIdCertManagerOverviewRoute:
authenticateOrgdetailsOrganizationOrganizationIdCertManagerOverviewRoute,
authenticateOrgdetailsOrganizationOrganizationIdKmsOverviewRoute:
authenticateOrgdetailsOrganizationOrganizationIdKmsOverviewRoute,
authenticateOrgdetailsOrganizationOrganizationIdSecretManagerOverviewRoute:
authenticateOrgdetailsOrganizationOrganizationIdSecretManagerOverviewRoute,
authenticateOrgdetailsOrganizationOrganizationIdAdminIndexRoute:
authenticateOrgdetailsOrganizationOrganizationIdAdminIndexRoute,
authenticateOrgdetailsOrganizationOrganizationIdAuditLogsIndexRoute:
authenticateOrgdetailsOrganizationOrganizationIdAuditLogsIndexRoute,
authenticateOrgdetailsOrganizationOrganizationIdBillingIndexRoute:
authenticateOrgdetailsOrganizationOrganizationIdBillingIndexRoute,
authenticateOrgdetailsOrganizationOrganizationIdMembersIndexRoute:
authenticateOrgdetailsOrganizationOrganizationIdMembersIndexRoute,
authenticateOrgdetailsOrganizationOrganizationIdOverviewIndexRoute:
authenticateOrgdetailsOrganizationOrganizationIdOverviewIndexRoute,
authenticateOrgdetailsOrganizationOrganizationIdSecretScanningIndexRoute:
authenticateOrgdetailsOrganizationOrganizationIdSecretScanningIndexRoute,
authenticateOrgdetailsOrganizationOrganizationIdSecretSharingIndexRoute:
authenticateOrgdetailsOrganizationOrganizationIdSecretSharingIndexRoute,
authenticateOrgdetailsOrganizationOrganizationIdSettingsIndexRoute:
authenticateOrgdetailsOrganizationOrganizationIdSettingsIndexRoute,
authenticateOrgdetailsOrganizationOrganizationIdGroupsGroupIdIndexRoute:
authenticateOrgdetailsOrganizationOrganizationIdGroupsGroupIdIndexRoute,
authenticateOrgdetailsOrganizationOrganizationIdIdentitiesIdentityIdIndexRoute:
authenticateOrgdetailsOrganizationOrganizationIdIdentitiesIdentityIdIndexRoute,
authenticateOrgdetailsOrganizationOrganizationIdMembershipsMembershipIdIndexRoute:
authenticateOrgdetailsOrganizationOrganizationIdMembershipsMembershipIdIndexRoute,
authenticateOrgdetailsOrganizationOrganizationIdRolesRoleIdIndexRoute:
authenticateOrgdetailsOrganizationOrganizationIdRolesRoleIdIndexRoute,
}
const authenticateOrgdetailsLayoutRouteWithChildren =
@@ -540,8 +808,22 @@ export interface FileRoutesByFullPath {
'/login/sso': typeof RestrictloginsignupLoginSsoIndexRoute
'/signup/sso': typeof RestrictloginsignupSignupSsoIndexRoute
'/organization': typeof authenticateOrgdetailsOrganizationIndexRoute
'/organization/$organizationId/secret-manager': typeof authenticateOrgdetailsOrganizationOrganizationIdSecretManagerRoute
'/organization/$organizationId': typeof authenticateOrgdetailsOrganizationOrganizationIdIndexRoute
'/organization/none': typeof authenticateOrgdetailsOrganizationNoneIndexRoute
'/organization/$organizationId/cert-manager/overview': typeof authenticateOrgdetailsOrganizationOrganizationIdCertManagerOverviewRoute
'/organization/$organizationId/kms/overview': typeof authenticateOrgdetailsOrganizationOrganizationIdKmsOverviewRoute
'/organization/$organizationId/secret-manager/overview': typeof authenticateOrgdetailsOrganizationOrganizationIdSecretManagerOverviewRoute
'/organization/$organizationId/admin': typeof authenticateOrgdetailsOrganizationOrganizationIdAdminIndexRoute
'/organization/$organizationId/audit-logs': typeof authenticateOrgdetailsOrganizationOrganizationIdAuditLogsIndexRoute
'/organization/$organizationId/billing': typeof authenticateOrgdetailsOrganizationOrganizationIdBillingIndexRoute
'/organization/$organizationId/members': typeof authenticateOrgdetailsOrganizationOrganizationIdMembersIndexRoute
'/organization/$organizationId/overview': typeof authenticateOrgdetailsOrganizationOrganizationIdOverviewIndexRoute
'/organization/$organizationId/secret-scanning': typeof authenticateOrgdetailsOrganizationOrganizationIdSecretScanningIndexRoute
'/organization/$organizationId/secret-sharing': typeof authenticateOrgdetailsOrganizationOrganizationIdSecretSharingIndexRoute
'/organization/$organizationId/settings': typeof authenticateOrgdetailsOrganizationOrganizationIdSettingsIndexRoute
'/organization/$organizationId/groups/$groupId': typeof authenticateOrgdetailsOrganizationOrganizationIdGroupsGroupIdIndexRoute
'/organization/$organizationId/identities/$identityId': typeof authenticateOrgdetailsOrganizationOrganizationIdIdentitiesIdentityIdIndexRoute
'/organization/$organizationId/memberships/$membershipId': typeof authenticateOrgdetailsOrganizationOrganizationIdMembershipsMembershipIdIndexRoute
'/organization/$organizationId/roles/$roleId': typeof authenticateOrgdetailsOrganizationOrganizationIdRolesRoleIdIndexRoute
}
export interface FileRoutesByTo {
@@ -563,8 +845,22 @@ export interface FileRoutesByTo {
'/login/sso': typeof RestrictloginsignupLoginSsoIndexRoute
'/signup/sso': typeof RestrictloginsignupSignupSsoIndexRoute
'/organization': typeof authenticateOrgdetailsOrganizationIndexRoute
'/organization/$organizationId/secret-manager': typeof authenticateOrgdetailsOrganizationOrganizationIdSecretManagerRoute
'/organization/$organizationId': typeof authenticateOrgdetailsOrganizationOrganizationIdIndexRoute
'/organization/none': typeof authenticateOrgdetailsOrganizationNoneIndexRoute
'/organization/$organizationId/cert-manager/overview': typeof authenticateOrgdetailsOrganizationOrganizationIdCertManagerOverviewRoute
'/organization/$organizationId/kms/overview': typeof authenticateOrgdetailsOrganizationOrganizationIdKmsOverviewRoute
'/organization/$organizationId/secret-manager/overview': typeof authenticateOrgdetailsOrganizationOrganizationIdSecretManagerOverviewRoute
'/organization/$organizationId/admin': typeof authenticateOrgdetailsOrganizationOrganizationIdAdminIndexRoute
'/organization/$organizationId/audit-logs': typeof authenticateOrgdetailsOrganizationOrganizationIdAuditLogsIndexRoute
'/organization/$organizationId/billing': typeof authenticateOrgdetailsOrganizationOrganizationIdBillingIndexRoute
'/organization/$organizationId/members': typeof authenticateOrgdetailsOrganizationOrganizationIdMembersIndexRoute
'/organization/$organizationId/overview': typeof authenticateOrgdetailsOrganizationOrganizationIdOverviewIndexRoute
'/organization/$organizationId/secret-scanning': typeof authenticateOrgdetailsOrganizationOrganizationIdSecretScanningIndexRoute
'/organization/$organizationId/secret-sharing': typeof authenticateOrgdetailsOrganizationOrganizationIdSecretSharingIndexRoute
'/organization/$organizationId/settings': typeof authenticateOrgdetailsOrganizationOrganizationIdSettingsIndexRoute
'/organization/$organizationId/groups/$groupId': typeof authenticateOrgdetailsOrganizationOrganizationIdGroupsGroupIdIndexRoute
'/organization/$organizationId/identities/$identityId': typeof authenticateOrgdetailsOrganizationOrganizationIdIdentitiesIdentityIdIndexRoute
'/organization/$organizationId/memberships/$membershipId': typeof authenticateOrgdetailsOrganizationOrganizationIdMembershipsMembershipIdIndexRoute
'/organization/$organizationId/roles/$roleId': typeof authenticateOrgdetailsOrganizationOrganizationIdRolesRoleIdIndexRoute
}
export interface FileRoutesById {
@@ -592,8 +888,22 @@ export interface FileRoutesById {
'/_restrict_login_signup/login/sso/': typeof RestrictloginsignupLoginSsoIndexRoute
'/_restrict_login_signup/signup/sso/': typeof RestrictloginsignupSignupSsoIndexRoute
'/_authenticate/_org_details/_org-layout/organization/': typeof authenticateOrgdetailsOrganizationIndexRoute
'/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-manager': typeof authenticateOrgdetailsOrganizationOrganizationIdSecretManagerRoute
'/_authenticate/_org_details/_org-layout/organization/$organizationId/': typeof authenticateOrgdetailsOrganizationOrganizationIdIndexRoute
'/_authenticate/_org_details/_org-layout/organization/none/': typeof authenticateOrgdetailsOrganizationNoneIndexRoute
'/_authenticate/_org_details/_org-layout/organization/$organizationId/cert-manager/overview': typeof authenticateOrgdetailsOrganizationOrganizationIdCertManagerOverviewRoute
'/_authenticate/_org_details/_org-layout/organization/$organizationId/kms/overview': typeof authenticateOrgdetailsOrganizationOrganizationIdKmsOverviewRoute
'/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-manager/overview': typeof authenticateOrgdetailsOrganizationOrganizationIdSecretManagerOverviewRoute
'/_authenticate/_org_details/_org-layout/organization/$organizationId/admin/': typeof authenticateOrgdetailsOrganizationOrganizationIdAdminIndexRoute
'/_authenticate/_org_details/_org-layout/organization/$organizationId/audit-logs/': typeof authenticateOrgdetailsOrganizationOrganizationIdAuditLogsIndexRoute
'/_authenticate/_org_details/_org-layout/organization/$organizationId/billing/': typeof authenticateOrgdetailsOrganizationOrganizationIdBillingIndexRoute
'/_authenticate/_org_details/_org-layout/organization/$organizationId/members/': typeof authenticateOrgdetailsOrganizationOrganizationIdMembersIndexRoute
'/_authenticate/_org_details/_org-layout/organization/$organizationId/overview/': typeof authenticateOrgdetailsOrganizationOrganizationIdOverviewIndexRoute
'/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-scanning/': typeof authenticateOrgdetailsOrganizationOrganizationIdSecretScanningIndexRoute
'/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-sharing/': typeof authenticateOrgdetailsOrganizationOrganizationIdSecretSharingIndexRoute
'/_authenticate/_org_details/_org-layout/organization/$organizationId/settings/': typeof authenticateOrgdetailsOrganizationOrganizationIdSettingsIndexRoute
'/_authenticate/_org_details/_org-layout/organization/$organizationId/groups/$groupId/': typeof authenticateOrgdetailsOrganizationOrganizationIdGroupsGroupIdIndexRoute
'/_authenticate/_org_details/_org-layout/organization/$organizationId/identities/$identityId/': typeof authenticateOrgdetailsOrganizationOrganizationIdIdentitiesIdentityIdIndexRoute
'/_authenticate/_org_details/_org-layout/organization/$organizationId/memberships/$membershipId/': typeof authenticateOrgdetailsOrganizationOrganizationIdMembershipsMembershipIdIndexRoute
'/_authenticate/_org_details/_org-layout/organization/$organizationId/roles/$roleId/': typeof authenticateOrgdetailsOrganizationOrganizationIdRolesRoleIdIndexRoute
}
export interface FileRouteTypes {
@@ -618,8 +928,22 @@ export interface FileRouteTypes {
| '/login/sso'
| '/signup/sso'
| '/organization'
| '/organization/$organizationId/secret-manager'
| '/organization/$organizationId'
| '/organization/none'
| '/organization/$organizationId/cert-manager/overview'
| '/organization/$organizationId/kms/overview'
| '/organization/$organizationId/secret-manager/overview'
| '/organization/$organizationId/admin'
| '/organization/$organizationId/audit-logs'
| '/organization/$organizationId/billing'
| '/organization/$organizationId/members'
| '/organization/$organizationId/overview'
| '/organization/$organizationId/secret-scanning'
| '/organization/$organizationId/secret-sharing'
| '/organization/$organizationId/settings'
| '/organization/$organizationId/groups/$groupId'
| '/organization/$organizationId/identities/$identityId'
| '/organization/$organizationId/memberships/$membershipId'
| '/organization/$organizationId/roles/$roleId'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -640,8 +964,22 @@ export interface FileRouteTypes {
| '/login/sso'
| '/signup/sso'
| '/organization'
| '/organization/$organizationId/secret-manager'
| '/organization/$organizationId'
| '/organization/none'
| '/organization/$organizationId/cert-manager/overview'
| '/organization/$organizationId/kms/overview'
| '/organization/$organizationId/secret-manager/overview'
| '/organization/$organizationId/admin'
| '/organization/$organizationId/audit-logs'
| '/organization/$organizationId/billing'
| '/organization/$organizationId/members'
| '/organization/$organizationId/overview'
| '/organization/$organizationId/secret-scanning'
| '/organization/$organizationId/secret-sharing'
| '/organization/$organizationId/settings'
| '/organization/$organizationId/groups/$groupId'
| '/organization/$organizationId/identities/$identityId'
| '/organization/$organizationId/memberships/$membershipId'
| '/organization/$organizationId/roles/$roleId'
id:
| '__root__'
| '/'
@@ -667,8 +1005,22 @@ export interface FileRouteTypes {
| '/_restrict_login_signup/login/sso/'
| '/_restrict_login_signup/signup/sso/'
| '/_authenticate/_org_details/_org-layout/organization/'
| '/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-manager'
| '/_authenticate/_org_details/_org-layout/organization/$organizationId/'
| '/_authenticate/_org_details/_org-layout/organization/none/'
| '/_authenticate/_org_details/_org-layout/organization/$organizationId/cert-manager/overview'
| '/_authenticate/_org_details/_org-layout/organization/$organizationId/kms/overview'
| '/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-manager/overview'
| '/_authenticate/_org_details/_org-layout/organization/$organizationId/admin/'
| '/_authenticate/_org_details/_org-layout/organization/$organizationId/audit-logs/'
| '/_authenticate/_org_details/_org-layout/organization/$organizationId/billing/'
| '/_authenticate/_org_details/_org-layout/organization/$organizationId/members/'
| '/_authenticate/_org_details/_org-layout/organization/$organizationId/overview/'
| '/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-scanning/'
| '/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-sharing/'
| '/_authenticate/_org_details/_org-layout/organization/$organizationId/settings/'
| '/_authenticate/_org_details/_org-layout/organization/$organizationId/groups/$groupId/'
| '/_authenticate/_org_details/_org-layout/organization/$organizationId/identities/$identityId/'
| '/_authenticate/_org_details/_org-layout/organization/$organizationId/memberships/$membershipId/'
| '/_authenticate/_org_details/_org-layout/organization/$organizationId/roles/$roleId/'
fileRoutesById: FileRoutesById
}
@@ -764,8 +1116,22 @@ export const routeTree = rootRoute
"parent": "/_authenticate/_org_details",
"children": [
"/_authenticate/_org_details/_org-layout/organization/",
"/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-manager",
"/_authenticate/_org_details/_org-layout/organization/$organizationId/"
"/_authenticate/_org_details/_org-layout/organization/none/",
"/_authenticate/_org_details/_org-layout/organization/$organizationId/cert-manager/overview",
"/_authenticate/_org_details/_org-layout/organization/$organizationId/kms/overview",
"/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-manager/overview",
"/_authenticate/_org_details/_org-layout/organization/$organizationId/admin/",
"/_authenticate/_org_details/_org-layout/organization/$organizationId/audit-logs/",
"/_authenticate/_org_details/_org-layout/organization/$organizationId/billing/",
"/_authenticate/_org_details/_org-layout/organization/$organizationId/members/",
"/_authenticate/_org_details/_org-layout/organization/$organizationId/overview/",
"/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-scanning/",
"/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-sharing/",
"/_authenticate/_org_details/_org-layout/organization/$organizationId/settings/",
"/_authenticate/_org_details/_org-layout/organization/$organizationId/groups/$groupId/",
"/_authenticate/_org_details/_org-layout/organization/$organizationId/identities/$identityId/",
"/_authenticate/_org_details/_org-layout/organization/$organizationId/memberships/$membershipId/",
"/_authenticate/_org_details/_org-layout/organization/$organizationId/roles/$roleId/"
]
},
"/_authenticate/personal-settings": {
@@ -822,12 +1188,68 @@ export const routeTree = rootRoute
"filePath": "_authenticate/_org_details/organization/index.tsx",
"parent": "/_authenticate/_org_details/_org-layout"
},
"/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-manager": {
"filePath": "_authenticate/_org_details/organization/$organizationId/secret-manager.tsx",
"/_authenticate/_org_details/_org-layout/organization/none/": {
"filePath": "_authenticate/_org_details/organization/none/index.tsx",
"parent": "/_authenticate/_org_details/_org-layout"
},
"/_authenticate/_org_details/_org-layout/organization/$organizationId/": {
"filePath": "_authenticate/_org_details/organization/$organizationId/index.tsx",
"/_authenticate/_org_details/_org-layout/organization/$organizationId/cert-manager/overview": {
"filePath": "_authenticate/_org_details/organization/$organizationId/cert-manager/overview.tsx",
"parent": "/_authenticate/_org_details/_org-layout"
},
"/_authenticate/_org_details/_org-layout/organization/$organizationId/kms/overview": {
"filePath": "_authenticate/_org_details/organization/$organizationId/kms/overview.tsx",
"parent": "/_authenticate/_org_details/_org-layout"
},
"/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-manager/overview": {
"filePath": "_authenticate/_org_details/organization/$organizationId/secret-manager/overview.tsx",
"parent": "/_authenticate/_org_details/_org-layout"
},
"/_authenticate/_org_details/_org-layout/organization/$organizationId/admin/": {
"filePath": "_authenticate/_org_details/organization/$organizationId/admin/index.tsx",
"parent": "/_authenticate/_org_details/_org-layout"
},
"/_authenticate/_org_details/_org-layout/organization/$organizationId/audit-logs/": {
"filePath": "_authenticate/_org_details/organization/$organizationId/audit-logs/index.tsx",
"parent": "/_authenticate/_org_details/_org-layout"
},
"/_authenticate/_org_details/_org-layout/organization/$organizationId/billing/": {
"filePath": "_authenticate/_org_details/organization/$organizationId/billing/index.tsx",
"parent": "/_authenticate/_org_details/_org-layout"
},
"/_authenticate/_org_details/_org-layout/organization/$organizationId/members/": {
"filePath": "_authenticate/_org_details/organization/$organizationId/members/index.tsx",
"parent": "/_authenticate/_org_details/_org-layout"
},
"/_authenticate/_org_details/_org-layout/organization/$organizationId/overview/": {
"filePath": "_authenticate/_org_details/organization/$organizationId/overview/index.tsx",
"parent": "/_authenticate/_org_details/_org-layout"
},
"/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-scanning/": {
"filePath": "_authenticate/_org_details/organization/$organizationId/secret-scanning/index.tsx",
"parent": "/_authenticate/_org_details/_org-layout"
},
"/_authenticate/_org_details/_org-layout/organization/$organizationId/secret-sharing/": {
"filePath": "_authenticate/_org_details/organization/$organizationId/secret-sharing/index.tsx",
"parent": "/_authenticate/_org_details/_org-layout"
},
"/_authenticate/_org_details/_org-layout/organization/$organizationId/settings/": {
"filePath": "_authenticate/_org_details/organization/$organizationId/settings/index.tsx",
"parent": "/_authenticate/_org_details/_org-layout"
},
"/_authenticate/_org_details/_org-layout/organization/$organizationId/groups/$groupId/": {
"filePath": "_authenticate/_org_details/organization/$organizationId/groups/$groupId/index.tsx",
"parent": "/_authenticate/_org_details/_org-layout"
},
"/_authenticate/_org_details/_org-layout/organization/$organizationId/identities/$identityId/": {
"filePath": "_authenticate/_org_details/organization/$organizationId/identities/$identityId/index.tsx",
"parent": "/_authenticate/_org_details/_org-layout"
},
"/_authenticate/_org_details/_org-layout/organization/$organizationId/memberships/$membershipId/": {
"filePath": "_authenticate/_org_details/organization/$organizationId/memberships/$membershipId/index.tsx",
"parent": "/_authenticate/_org_details/_org-layout"
},
"/_authenticate/_org_details/_org-layout/organization/$organizationId/roles/$roleId/": {
"filePath": "_authenticate/_org_details/organization/$organizationId/roles/$roleId/index.tsx",
"parent": "/_authenticate/_org_details/_org-layout"
}
}

View File

@@ -0,0 +1,168 @@
import { useState } from "react";
import { faEllipsis, faMagnifyingGlass, faSignIn } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { motion } from "framer-motion";
import { createNotification } from "@app/components/notifications";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Input,
Pagination,
Spinner,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import {
OrgPermissionAdminConsoleAction,
OrgPermissionSubjects
} from "@app/context/OrgPermissionContext/types";
import { withPermission } from "@app/hoc";
import { useDebounce } from "@app/hooks";
import { useOrgAdminAccessProject, useOrgAdminGetProjects } from "@app/hooks/api";
import { useNavigate } from "@tanstack/react-router";
import { ProjectType } from "@app/hooks/api/workspace/types";
export const OrgAdminProjects = withPermission(
() => {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebounce(search);
const [perPage, setPerPage] = useState(25);
const navigate = useNavigate();
const orgAdminAccessProject = useOrgAdminAccessProject();
const { data, isLoading: isProjectsLoading } = useOrgAdminGetProjects({
offset: (page - 1) * perPage,
limit: perPage,
search: debouncedSearch || undefined
});
const projects = data?.projects || [];
const projectCount = data?.count || 0;
const isEmpty = !isProjectsLoading && projects.length === 0;
const handleAccessProject = async (type: ProjectType, projectId: string) => {
try {
await orgAdminAccessProject.mutateAsync({
projectId
});
await navigate({
to: `/${type}/$projectId/secrets/overview` as const,
params: {
projectId
}
});
} catch {
createNotification({
text: "Failed to access project",
type: "error"
});
}
};
return (
<motion.div
key="panel-projects"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Projects</p>
</div>
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search by project name"
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Slug</Th>
<Th>Created At</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isProjectsLoading && <TableSkeleton columns={4} innerKey="projects" />}
{!isProjectsLoading &&
projects?.map(({ name, slug, createdAt, type, id }) => (
<Tr key={`project-${id}`} className="group w-full">
<Td>{name}</Td>
<Td>{slug}</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd, hh:mm aaa")}</Td>
<Td>
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<Button
variant="link"
className="text-bunker-300 hover:text-primary-400 data-[state=open]:text-primary-400"
>
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleAccessProject(type, id);
}}
icon={<FontAwesomeIcon icon={faSignIn} />}
disabled={
orgAdminAccessProject.variables?.projectId === id &&
orgAdminAccessProject.isLoading
}
>
Access{" "}
{orgAdminAccessProject.variables?.projectId === id &&
orgAdminAccessProject.isLoading && <Spinner size="xs" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Td>
</Tr>
))}
</TBody>
</Table>
{!isProjectsLoading && (
<Pagination
count={projectCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{isEmpty && <EmptyState title="No projects found" />}
</TableContainer>
</div>
</div>
</motion.div>
);
},
{
action: OrgPermissionAdminConsoleAction.AccessAllProjects,
subject: OrgPermissionSubjects.AdminConsole
}
);

View File

@@ -0,0 +1 @@
export { OrgAdminProjects } from "./OrgAdminProjects";

View File

@@ -0,0 +1,53 @@
import { useState } from 'react'
import { Helmet } from 'react-helmet'
import { useTranslation } from 'react-i18next'
import { createFileRoute } from '@tanstack/react-router'
import { Tab, TabList, TabPanel, Tabs } from '@app/components/v2'
import { OrgAdminProjects } from './-components/OrgAdminProjects'
enum TabSections {
Projects = 'projects',
}
const OrgAdminPage = () => {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<TabSections>(TabSections.Projects)
return (
<>
<Helmet>
<title>
{t('common.head-title', { title: t('settings.org.title') })}
</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<div className="flex w-full justify-center bg-bunker-800 py-6 text-white">
<div className="w-full max-w-6xl px-6">
<div className="mb-4">
<p className="text-3xl font-semibold text-gray-200">
Organization Admin Console
</p>
</div>
<Tabs
value={activeTab}
onValueChange={(el) => setActiveTab(el as TabSections)}
>
<TabList>
<Tab value={TabSections.Projects}>Projects</Tab>
</TabList>
<TabPanel value={TabSections.Projects}>
<OrgAdminProjects />
</TabPanel>
</Tabs>
</div>
</div>
</>
)
}
export const Route = createFileRoute(
'/_authenticate/_org_details/_org-layout/organization/$organizationId/admin/',
)({
component: OrgAdminPage,
})

View File

@@ -0,0 +1,325 @@
/* eslint-disable no-nested-ternary */
import { useEffect, useState } from "react";
import { Control, Controller, UseFormReset, UseFormSetValue, UseFormWatch } from "react-hook-form";
import { faCaretDown, faCheckCircle, faFilterCircleXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import {
Button,
DatePicker,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
FilterableSelect,
FormControl,
Select,
SelectItem
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useGetAuditLogActorFilterOpts, useGetUserWorkspaces } from "@app/hooks/api";
import { eventToNameMap, userAgentTTypeoNameMap } from "@app/hooks/api/auditLogs/constants";
import { ActorType, EventType } from "@app/hooks/api/auditLogs/enums";
import { Actor } from "@app/hooks/api/auditLogs/types";
import { AuditLogFilterFormData } from "./types";
const eventTypes = Object.entries(eventToNameMap).map(([value, label]) => ({ label, value }));
const userAgentTypes = Object.entries(userAgentTTypeoNameMap).map(([value, label]) => ({
label,
value
}));
type Props = {
presets?: {
actorId?: string;
eventType?: EventType[];
};
className?: string;
isOrgAuditLogs?: boolean;
setValue: UseFormSetValue<AuditLogFilterFormData>;
control: Control<AuditLogFilterFormData>;
reset: UseFormReset<AuditLogFilterFormData>;
watch: UseFormWatch<AuditLogFilterFormData>;
};
export const LogsFilter = ({
presets,
isOrgAuditLogs,
className,
control,
setValue,
reset,
watch
}: Props) => {
const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false);
const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false);
const { data: workspaces = [] } = useGetUserWorkspaces();
const { currentOrg } = useOrganization();
const workspacesInOrg = workspaces.filter((ws) => ws.orgId === currentOrg?.id);
const { data, isLoading } = useGetAuditLogActorFilterOpts(workspaces?.[0]?.id ?? "");
useEffect(() => {
if (workspacesInOrg.length) {
setValue("project", workspacesInOrg[0]);
}
}, [workspaces]);
const renderActorSelectItem = (actor: Actor) => {
switch (actor.type) {
case ActorType.USER:
return (
<SelectItem
value={`${actor.type}-${actor.metadata.userId}`}
key={`user-actor-filter-${actor.metadata.userId}`}
>
{actor.metadata.email}
</SelectItem>
);
case ActorType.SERVICE:
return (
<SelectItem
value={`${actor.type}-${actor.metadata.serviceId}`}
key={`service-actor-filter-${actor.metadata.serviceId}`}
>
{actor.metadata.name}
</SelectItem>
);
case ActorType.IDENTITY:
return (
<SelectItem
value={`${actor.type}-${actor.metadata.identityId}`}
key={`identity-filter-${actor.metadata.identityId}`}
>
{actor.metadata.name}
</SelectItem>
);
default:
return (
<SelectItem value="actor-none" key="actor-none">
N/A
</SelectItem>
);
}
};
const selectedEventTypes = watch("eventType") as EventType[] | undefined;
return (
<div
className={twMerge(
"sticky top-20 z-10 flex flex-wrap items-center justify-between bg-bunker-800",
className
)}
>
{isOrgAuditLogs && workspacesInOrg.length > 0 && (
<Controller
control={control}
name="project"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Project"
errorText={error?.message}
isError={Boolean(error)}
className="mr-12 w-64"
>
<FilterableSelect
value={value}
onChange={onChange}
placeholder="Select a project..."
options={workspacesInOrg.map(({ name, id }) => ({ name, id }))}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
)}
<div className="mt-1 flex items-center space-x-2">
<Controller
control={control}
name="eventType"
render={({ field }) => (
<FormControl label="Events">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="inline-flex w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md border border-mineshaft-500 bg-mineshaft-700 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
{selectedEventTypes?.length === 1
? eventTypes.find((eventType) => eventType.value === selectedEventTypes[0])
?.label
: selectedEventTypes?.length === 0
? "All events"
: `${selectedEventTypes?.length} events selected`}
<FontAwesomeIcon icon={faCaretDown} className="ml-2 text-xs" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[100] max-h-80 overflow-hidden">
<div className="max-h-80 overflow-y-auto">
{eventTypes && eventTypes.length > 0 ? (
eventTypes.map((eventType) => {
const isSelected = selectedEventTypes?.includes(
eventType.value as EventType
);
return (
<DropdownMenuItem
onSelect={(event) => eventTypes.length > 1 && event.preventDefault()}
onClick={() => {
if (selectedEventTypes?.includes(eventType.value as EventType)) {
field.onChange(
selectedEventTypes?.filter((e: string) => e !== eventType.value)
);
} else {
field.onChange([...(selectedEventTypes || []), eventType.value]);
}
}}
key={`event-type-${eventType.value}`}
icon={
isSelected ? (
<FontAwesomeIcon
icon={faCheckCircle}
className="pr-0.5 text-primary"
/>
) : (
<div className="pl-[1.01rem]" />
)
}
iconPos="left"
className="w-[28.4rem] text-sm"
>
{eventType.label}
</DropdownMenuItem>
);
})
) : (
<div />
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
{!isLoading && data && data.length > 0 && !presets?.actorId && (
<Controller
control={control}
name="actor"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Actor"
errorText={error?.message}
isError={Boolean(error)}
className="w-40"
>
<Select
{...(field.value ? { value: field.value } : { placeholder: "Select" })}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100"
>
{data.map((actor) => renderActorSelectItem(actor))}
</Select>
</FormControl>
)}
/>
)}
<Controller
control={control}
name="userAgentType"
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
<FormControl
label="Source"
errorText={error?.message}
isError={Boolean(error)}
className="w-40"
>
<Select
value={value === undefined ? "all" : value}
{...field}
onValueChange={(e) => {
if (e === "all") onChange(undefined);
else onChange(e);
}}
className={twMerge("w-full border border-mineshaft-500 bg-mineshaft-700")}
>
<SelectItem value="all" key="all">
All sources
</SelectItem>
{userAgentTypes.map(({ label, value: userAgent }) => (
<SelectItem value={userAgent} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
name="startDate"
control={control}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl label="Start date" errorText={error?.message} isError={Boolean(error)}>
<DatePicker
value={field.value || undefined}
onChange={onChange}
dateFormat="P"
popUpProps={{
open: isStartDatePickerOpen,
onOpenChange: setIsStartDatePickerOpen
}}
popUpContentProps={{}}
/>
</FormControl>
);
}}
/>
<Controller
name="endDate"
control={control}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl label="End date" errorText={error?.message} isError={Boolean(error)}>
<DatePicker
value={field.value || undefined}
onChange={onChange}
dateFormat="P"
popUpProps={{
open: isEndDatePickerOpen,
onOpenChange: setIsEndDatePickerOpen
}}
popUpContentProps={{}}
/>
</FormControl>
);
}}
/>
<Button
isLoading={false}
colorSchema="primary"
variant="outline_bg"
className="mt-[0.45rem]"
type="submit"
leftIcon={<FontAwesomeIcon icon={faFilterCircleXmark} />}
onClick={() =>
reset({
eventType: presets?.eventType || [],
actor: presets?.actorId,
userAgentType: undefined,
startDate: undefined,
endDate: undefined,
project: null
})
}
>
Clear filters
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,115 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { UpgradePlanModal } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
import { withPermission } from "@app/hoc";
import { ActorType, EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
import { usePopUp } from "@app/hooks/usePopUp";
import { LogsFilter } from "./LogsFilter";
import { LogsTable, TAuditLogTableHeader } from "./LogsTable";
import { AuditLogFilterFormData, auditLogFilterFormSchema } from "./types";
type Props = {
presets?: {
actorId?: string;
eventType?: EventType[];
actorType?: ActorType;
startDate?: Date;
endDate?: Date;
eventMetadata?: Record<string, string>;
};
showFilters?: boolean;
filterClassName?: string;
isOrgAuditLogs?: boolean;
showActorColumn?: boolean;
remappedHeaders?: Partial<Record<TAuditLogTableHeader, string>>;
refetchInterval?: number;
};
export const LogsSection = withPermission(
({
presets,
filterClassName,
remappedHeaders,
isOrgAuditLogs,
showActorColumn,
refetchInterval,
showFilters
}: Props) => {
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
const { control, reset, watch, setValue } = useForm<AuditLogFilterFormData>({
resolver: zodResolver(auditLogFilterFormSchema),
defaultValues: {
project: null,
actor: presets?.actorId,
eventType: presets?.eventType || [],
page: 1,
perPage: 10,
startDate: presets?.startDate ?? new Date(new Date().setDate(new Date().getDate() - 1)), // day before today
endDate: presets?.endDate ?? new Date(new Date(Date.now()).setHours(23, 59, 59, 999)) // end of today
}
});
useEffect(() => {
if (subscription && !subscription.auditLogs) {
handlePopUpOpen("upgradePlan");
}
}, [subscription]);
const eventType = watch("eventType") as EventType[] | undefined;
const userAgentType = watch("userAgentType") as UserAgentType | undefined;
const actor = watch("actor");
const projectId = watch("project")?.id;
const startDate = watch("startDate");
const endDate = watch("endDate");
return (
<div>
{showFilters && (
<LogsFilter
isOrgAuditLogs
className={filterClassName}
presets={presets}
control={control}
setValue={setValue}
watch={watch}
reset={reset}
/>
)}
<LogsTable
refetchInterval={refetchInterval}
remappedHeaders={remappedHeaders}
isOrgAuditLogs={isOrgAuditLogs}
showActorColumn={!!showActorColumn}
filter={{
eventMetadata: presets?.eventMetadata,
projectId,
actorType: presets?.actorType,
limit: 15,
eventType,
userAgentType,
startDate,
endDate,
actor
}}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("upgradePlan", isOpen);
}}
text="You can use audit logs if you switch to a paid Infisical plan."
/>
</div>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.AuditLogs }
);

View File

@@ -0,0 +1,119 @@
import { Fragment } from "react";
import { faFile } from "@fortawesome/free-solid-svg-icons";
import {
Button,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useGetAuditLogs } from "@app/hooks/api";
import { TGetAuditLogsFilter } from "@app/hooks/api/auditLogs/types";
import { LogsTableRow } from "./LogsTableRow";
type Props = {
isOrgAuditLogs?: boolean;
showActorColumn: boolean;
filter?: TGetAuditLogsFilter;
remappedHeaders?: Partial<Record<TAuditLogTableHeader, string>>;
refetchInterval?: number;
};
const AUDIT_LOG_LIMIT = 15;
const TABLE_HEADERS = ["Timestamp", "Event", "Project", "Actor", "Source", "Metadata"] as const;
export type TAuditLogTableHeader = (typeof TABLE_HEADERS)[number];
export const LogsTable = ({
showActorColumn,
isOrgAuditLogs,
filter,
remappedHeaders,
refetchInterval
}: Props) => {
// TODO(rbr): check this again, there was a filter with current workspace id
// Determine the project ID for filtering
const filterProjectId =
// Use the projectId from the filter if it exists
filter?.projectId || null;
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useGetAuditLogs(
{
...filter,
limit: AUDIT_LOG_LIMIT
},
filterProjectId,
{
refetchInterval
}
);
const isEmpty = !isLoading && !data?.pages?.[0].length;
return (
<div>
<TableContainer>
<Table>
<THead>
<Tr>
{TABLE_HEADERS.map((header, idx) => {
if (
(header === "Project" && !isOrgAuditLogs) ||
(header === "Actor" && !showActorColumn)
) {
return null;
}
return (
<Th key={`table-header-${idx + 1}`}>{remappedHeaders?.[header] || header}</Th>
);
})}
</Tr>
</THead>
<TBody>
{!isLoading &&
data?.pages?.map((group, i) => (
<Fragment key={`audit-log-fragment-${i + 1}`}>
{group.map((auditLog) => (
<LogsTableRow
showActorColumn={showActorColumn}
isOrgAuditLogs={isOrgAuditLogs}
auditLog={auditLog}
key={`audit-log-${auditLog.id}`}
/>
))}
</Fragment>
))}
{isLoading && <TableSkeleton innerKey="logs-table" columns={5} key="logs" />}
{isEmpty && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No audit logs on file" icon={faFile} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
{!isEmpty && (
<Button
className="mb-20 mt-4 px-4 py-3 text-sm"
isFullWidth
variant="star"
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()}
>
{hasNextPage ? "Load More" : "End of logs"}
</Button>
)}
</div>
);
};

View File

@@ -0,0 +1,101 @@
import { Td, Tr } from "@app/components/v2";
import { eventToNameMap, userAgentTTypeoNameMap } from "@app/hooks/api/auditLogs/constants";
import { ActorType, EventType } from "@app/hooks/api/auditLogs/enums";
import { Actor, AuditLog } from "@app/hooks/api/auditLogs/types";
type Props = {
auditLog: AuditLog;
isOrgAuditLogs?: boolean;
showActorColumn: boolean;
};
export const LogsTableRow = ({ auditLog, isOrgAuditLogs, showActorColumn }: Props) => {
const renderActor = (actor: Actor) => {
if (!actor) {
return <Td />;
}
switch (actor.type) {
case ActorType.USER:
return (
<Td>
<p>{actor.metadata.email}</p>
<p>User</p>
</Td>
);
case ActorType.SERVICE:
return (
<Td>
<p>{`${actor.metadata.name}`}</p>
<p>Service token</p>
</Td>
);
case ActorType.IDENTITY:
return (
<Td>
<p>{`${actor.metadata.name}`}</p>
<p>Machine Identity</p>
</Td>
);
default:
return <Td />;
}
};
const formatDate = (dateToFormat: string) => {
const date = new Date(dateToFormat);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
let hours = date.getHours();
const minutes = String(date.getMinutes()).padStart(2, "0");
// convert from 24h to 12h format
const period = hours >= 12 ? "PM" : "AM";
hours %= 12;
hours = hours || 12; // the hour '0' should be '12'
const formattedDate = `${day}-${month}-${year} at ${hours}:${minutes} ${period}`;
return formattedDate;
};
const renderSource = () => {
const { event, actor } = auditLog;
if (event.type === EventType.INTEGRATION_SYNCED) {
if (actor.type === ActorType.USER) {
return (
<Td>
<p>Manually triggered by {actor.metadata.email}</p>
</Td>
);
}
// Platform / automatic syncs
return (
<Td>
<p>Automatically synced by Infisical</p>
</Td>
);
}
return (
<Td>
<p>{userAgentTTypeoNameMap[auditLog.userAgentType]}</p>
<p>{auditLog.ipAddress}</p>
</Td>
);
};
return (
<Tr className={`log-${auditLog.id} h-10 border-x-0 border-b border-t-0`}>
<Td>{formatDate(auditLog.createdAt)}</Td>
<Td>{`${eventToNameMap[auditLog.event.type]}`}</Td>
{isOrgAuditLogs && <Td>{auditLog?.projectName ?? auditLog?.projectId ?? "N/A"}</Td>}
{showActorColumn && renderActor(auditLog.actor)}
{renderSource()}
<Td className="max-w-xs break-all">{JSON.stringify(auditLog.event.metadata || {})}</Td>
</Tr>
);
};

View File

@@ -0,0 +1 @@
export { LogsSection } from "./LogsSection";

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
// TODO(rbr): test this schema
export const auditLogFilterFormSchema = z
.object({
eventMetadata: z.object({}).optional(),
project: z.object({ id: z.string(), name: z.string() }).optional().nullable(),
eventType: z.nativeEnum(EventType).array(),
actor: z.string().optional(),
userAgentType: z.nativeEnum(UserAgentType),
startDate: z.date().optional(),
endDate: z.date().optional(),
page: z.coerce.number().optional(),
perPage: z.coerce.number().optional()
})
.superRefine((el, ctx) => {
if (el.endDate && el.startDate && el.endDate < el.startDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["endDate"],
message: "End date cannot be before start date"
});
}
});
export type AuditLogFilterFormData = z.infer<typeof auditLogFilterFormSchema>;
export type SetValueType = (
name: keyof AuditLogFilterFormData,
value: any,
options?: {
shouldValidate?: boolean;
shouldDirty?: boolean;
}
) => void;

View File

@@ -0,0 +1,31 @@
import { Helmet } from "react-helmet";
import { createFileRoute } from "@tanstack/react-router";
import { LogsSection } from "./-components";
const AuditLogsPage = () => {
return (
<div className="h-full bg-bunker-800">
<Helmet>
<title>Infisical | Audit Logs</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
</Helmet>
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl px-6">
<div className="bg-bunker-800 py-6">
<p className="text-3xl font-semibold text-gray-200">Audit Logs</p>
<div />
</div>
<LogsSection filterClassName="static py-2" showFilters isOrgAuditLogs showActorColumn />
</div>
</div>
</div>
);
};
export const Route = createFileRoute(
"/_authenticate/_org_details/_org-layout/organization/$organizationId/audit-logs/"
)({
component: AuditLogsPage
});

View File

@@ -0,0 +1,11 @@
import { CurrentPlanSection } from "./CurrentPlanSection";
import { PreviewSection } from "./PreviewSection";
export const BillingCloudTab = () => {
return (
<div>
<PreviewSection />
<CurrentPlanSection />
</div>
);
};

View File

@@ -0,0 +1,72 @@
import { faCircleCheck, faCircleXmark, faFileInvoice } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useGetOrgPlanTable } from "@app/hooks/api";
export const CurrentPlanSection = () => {
const { currentOrg } = useOrganization();
const { data, isLoading } = useGetOrgPlanTable(currentOrg?.id ?? "");
const displayCell = (value: null | number | string | boolean) => {
if (value === null) return "-";
if (typeof value === "boolean") {
if (value) return <FontAwesomeIcon icon={faCircleCheck} color="#2ecc71" />;
return <FontAwesomeIcon icon={faCircleXmark} color="#e74c3c" />;
}
return value;
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<h2 className="mb-8 flex-1 text-xl font-semibold text-white">Current usage</h2>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/3">Feature</Th>
<Th className="w-1/3">Allowed</Th>
<Th className="w-1/3">Used</Th>
</Tr>
</THead>
<TBody>
{!isLoading &&
data &&
data?.rows?.length > 0 &&
data.rows.map(({ name, allowed, used }) => {
return (
<Tr key={`current-plan-row-${name}`} className="h-12">
<Td>{name}</Td>
<Td>{displayCell(allowed)}</Td>
<Td>{used}</Td>
</Tr>
);
})}
{isLoading && <TableSkeleton columns={5} innerKey="invoices" />}
{!isLoading && data && data?.rows?.length === 0 && (
<Tr>
<Td colSpan={3}>
<EmptyState title="No plan details found" icon={faFileInvoice} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
</div>
);
};

View File

@@ -0,0 +1,62 @@
import { Fragment } from "react";
import { Tab } from "@headlessui/react";
import { Modal, ModalContent } from "@app/components/v2";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { ManagePlansTable } from "./ManagePlansTable";
type Props = {
popUp: UsePopUpState<["managePlan"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["managePlan"]>, state?: boolean) => void;
};
export const ManagePlansModal = ({ popUp, handlePopUpToggle }: Props) => {
return (
<Modal
isOpen={popUp?.managePlan?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("managePlan", isOpen);
}}
>
<ModalContent className="max-w-screen-lg" title="Infisical Cloud Plans">
<Tab.Group>
<Tab.List className="max-w-screen-lg border-b-2 border-mineshaft-600">
<Tab as={Fragment}>
{({ selected }) => (
<button
type="button"
className={`p-4 ${
selected ? "border-b-2 border-white text-white" : "text-mineshaft-400"
} w-30 font-semibold outline-none`}
>
Bill monthly
</button>
)}
</Tab>
<Tab as={Fragment}>
{({ selected }) => (
<button
type="button"
className={`p-4 ${
selected ? "border-b-2 border-white text-white" : "text-mineshaft-400"
} w-30 font-semibold outline-none`}
>
Bill yearly
</button>
)}
</Tab>
</Tab.List>
<Tab.Panels className="mt-4">
<Tab.Panel>
<ManagePlansTable billingCycle="monthly" />
</Tab.Panel>
<Tab.Panel>
<ManagePlansTable billingCycle="yearly" />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,138 @@
import { faCircleCheck, faCircleXmark, faFileInvoice } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import { useCreateCustomerPortalSession, useGetOrgPlansTable } from "@app/hooks/api";
type Props = {
billingCycle: "monthly" | "yearly";
};
export const ManagePlansTable = ({ billingCycle }: Props) => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { data: tableData, isLoading: isTableDataLoading } = useGetOrgPlansTable({
organizationId: currentOrg?.id ?? "",
billingCycle
});
const createCustomerPortalSession = useCreateCustomerPortalSession();
const displayCell = (value: null | number | string | boolean) => {
if (value === null) return "Unlimited";
if (typeof value === "boolean") {
if (value) return <FontAwesomeIcon icon={faCircleCheck} color="#2ecc71" />;
return <FontAwesomeIcon icon={faCircleXmark} color="#e74c3c" />;
}
return value;
};
return (
<TableContainer>
<Table>
<THead>
{subscription && !isTableDataLoading && tableData && (
<Tr>
<Th className="">Feature / Limit</Th>
{tableData.head.map(({ name, priceLine }) => {
return (
<Th
key={`plans-feature-head-${billingCycle}-${name}`}
className="flex-1 text-center"
>
<p>{name}</p>
<p>{priceLine}</p>
</Th>
);
})}
</Tr>
)}
</THead>
<TBody>
{subscription &&
!isTableDataLoading &&
tableData &&
tableData.rows.map(({ name, starter, pro, enterprise }) => {
return (
<Tr className="h-12" key={`plans-feature-row-${billingCycle}-${name}`}>
<Td>{displayCell(name)}</Td>
<Td className="text-center">{displayCell(starter)}</Td>
<Td className="text-center">{displayCell(pro)}</Td>
<Td className="text-center">{displayCell(enterprise)}</Td>
</Tr>
);
})}
{isTableDataLoading && <TableSkeleton columns={5} innerKey="cloud-products" />}
{!isTableDataLoading && tableData?.rows.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No cloud product details found" icon={faFileInvoice} />
</Td>
</Tr>
)}
{subscription && !isTableDataLoading && tableData && (
<Tr className="h-12">
<Td />
{tableData.head.map(({ slug, tier }) => {
const isCurrentPlan = slug === subscription.slug;
let subscriptionText = "Upgrade";
if (subscription.tier > tier) {
subscriptionText = "Downgrade";
}
if (tier === 3) {
subscriptionText = "Contact sales";
}
return isCurrentPlan ? (
<Td>
<Button colorSchema="secondary" className="w-full" isDisabled>
Current
</Button>
</Td>
) : (
<Td>
<Button
onClick={async () => {
if (!currentOrg?.id) return;
if (tier !== 3) {
const { url } = await createCustomerPortalSession.mutateAsync(
currentOrg.id
);
window.location.href = url;
return;
}
window.location.href = "https://infisical.com/scheduledemo";
}}
color="mineshaft"
className="w-full"
>
{subscriptionText}
</Button>
</Td>
);
})}
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};

View File

@@ -0,0 +1,172 @@
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} from "@app/context";
import {
useCreateCustomerPortalSession,
useGetOrgPlanBillingInfo,
useGetOrgTrialUrl
} from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { ManagePlansModal } from "./ManagePlansModal";
export const PreviewSection = () => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { data, isLoading } = useGetOrgPlanBillingInfo(currentOrg?.id ?? "");
const getOrgTrialUrl = useGetOrgTrialUrl();
const createCustomerPortalSession = useCreateCustomerPortalSession();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["managePlan"] as const);
const formatAmount = (amount: number) => {
const formattedTotal = (Math.floor(amount) / 100).toLocaleString("en-US", {
style: "currency",
currency: "USD"
});
return formattedTotal;
};
const formatDate = (date: number) => {
const createdDate = new Date(date * 1000);
const day: number = createdDate.getDate();
const month: number = createdDate.getMonth() + 1;
const year: number = createdDate.getFullYear();
const formattedDate: string = `${day}/${month}/${year}`;
return formattedDate;
};
function formatPlanSlug(slug: string) {
return slug.replace(/(\b[a-z])/g, (match) => match.toUpperCase()).replace(/-/g, " ");
}
const handleUpgradeBtnClick = async () => {
try {
if (!subscription || !currentOrg) return;
if (!subscription.has_used_trial) {
// direct user to start pro trial
const url = await getOrgTrialUrl.mutateAsync({
orgId: currentOrg.id,
success_url: window.location.href
});
window.location.href = url;
} else {
// open compare plans modal
handlePopUpOpen("managePlan");
}
} catch (err) {
console.error(err);
}
};
return (
<div>
{subscription &&
subscription?.slug !== "enterprise" &&
subscription?.slug !== "pro" &&
subscription?.slug !== "pro-annual" && (
<div className="flex flex-row space-x-6">
<div className="mb-6 flex flex-1 items-center rounded-lg border border-primary/40 bg-primary/10 p-4">
<div className="flex-1">
<h2 className="text-xl font-medium text-mineshaft-100">
Unleash the full power of{" "}
<span className="bg-gradient-to-r from-primary-500 to-yellow bg-clip-text font-semibold text-transparent">
Infisical
</span>
</h2>
<p className="mt-4 text-gray-400">
Get unlimited members, projects, RBAC, smart alerts, and so much more.
</p>
</div>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Billing}>
{(isAllowed) => (
<Button
onClick={() => handleUpgradeBtnClick()}
color="mineshaft"
isDisabled={!isAllowed}
>
{!subscription.has_used_trial ? "Start Pro Free Trial" : "Upgrade Plan"}
</Button>
)}
</OrgPermissionCan>
</div>
<div className="mb-6 flex w-full max-w-[12rem] flex-col items-center rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-4">
<div className="mb-4 flex w-full justify-center font-semibold text-mineshaft-200">
Want to learn more?{" "}
</div>
<div className="flex w-full justify-center">
<a
href="https://infisical.com/schedule-demo"
target="_blank"
rel="noopener noreferrer"
>
<span className="cursor-pointer rounded-full border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 duration-200 hover:border-primary/40 hover:bg-primary/10">
Book a demo{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</a>
</div>
</div>
</div>
)}
{!isLoading && subscription && data && (
<div className="mb-6 flex">
<div className="mr-4 flex-1 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-2 text-gray-400">Current plan</p>
<p className="mb-8 text-2xl font-semibold text-mineshaft-50">
{`${formatPlanSlug(subscription.slug)} ${
subscription.status === "trialing" ? "(Trial)" : ""
}`}
</p>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Billing}>
{(isAllowed) => (
<button
type="button"
onClick={async () => {
if (!currentOrg?.id) return;
const { url } = await createCustomerPortalSession.mutateAsync(currentOrg.id);
window.location.href = url;
}}
disabled={!isAllowed}
className="text-primary"
>
Manage plan &rarr;
</button>
)}
</OrgPermissionCan>
</div>
<div className="mr-4 flex-1 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-2 text-gray-400">Price</p>
<p className="mb-8 text-2xl font-semibold text-mineshaft-50">
{subscription.status === "trialing"
? "$0.00 / month"
: `${formatAmount(data.amount)} / ${data.interval}`}
</p>
</div>
<div className="flex-1 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-2 text-gray-400">Subscription renews on</p>
<p className="mb-8 text-2xl font-semibold text-mineshaft-50">
{formatDate(data.currentPeriodEnd)}
</p>
</div>
</div>
)}
<ManagePlansModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
</div>
);
};

View File

@@ -0,0 +1 @@
export { BillingCloudTab } from "./BillingCloudTab";

View File

@@ -0,0 +1,15 @@
import { CompanyNameSection } from "./CompanyNameSection";
import { InvoiceEmailSection } from "./InvoiceEmailSection";
import { PmtMethodsSection } from "./PmtMethodsSection";
import { TaxIDSection } from "./TaxIDSection";
export const BillingDetailsTab = () => {
return (
<>
<CompanyNameSection />
<InvoiceEmailSection />
<PmtMethodsSection />
<TaxIDSection />
</>
);
};

View File

@@ -0,0 +1,91 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useGetOrgBillingDetails, useUpdateOrgBillingDetails } from "@app/hooks/api";
const schema = z
.object({
name: z.string()
})
.required();
export const CompanyNameSection = () => {
const { currentOrg } = useOrganization();
const { reset, control, handleSubmit } = useForm({
defaultValues: {
name: ""
},
resolver: zodResolver(schema)
});
const { data } = useGetOrgBillingDetails(currentOrg?.id ?? "");
const { mutateAsync, isLoading } = useUpdateOrgBillingDetails();
useEffect(() => {
if (data) {
reset({
name: data?.name ?? ""
});
}
}, [data]);
const onFormSubmit = async ({ name }: { name: string }) => {
try {
if (!currentOrg?.id) return;
if (name === "") return;
await mutateAsync({
name,
organizationId: currentOrg.id
});
createNotification({
text: "Successfully updated business name",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update business name",
type: "error"
});
}
};
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<h2 className="mb-8 flex-1 text-xl font-semibold text-mineshaft-100">Business name</h2>
<div className="max-w-md">
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input placeholder="Acme Corp" {...field} className="bg-mineshaft-800" />
</FormControl>
)}
control={control}
name="name"
/>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Billing}>
{(isAllowed) => (
<Button
type="submit"
colorSchema="secondary"
isLoading={isLoading}
isDisabled={isLoading || !isAllowed}
>
Save
</Button>
)}
</OrgPermissionCan>
</form>
);
};

View File

@@ -0,0 +1,92 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useGetOrgBillingDetails, useUpdateOrgBillingDetails } from "@app/hooks/api";
const schema = z
.object({
email: z.string()
})
.required();
export const InvoiceEmailSection = () => {
const { currentOrg } = useOrganization();
const { reset, control, handleSubmit } = useForm({
defaultValues: {
email: ""
},
resolver: yupResolver(schema)
});
const { data } = useGetOrgBillingDetails(currentOrg?.id ?? "");
const { mutateAsync, isLoading } = useUpdateOrgBillingDetails();
useEffect(() => {
if (data) {
reset({
email: data?.email ?? ""
});
}
}, [data]);
const onFormSubmit = async ({ email }: { email: string }) => {
try {
if (!currentOrg?.id) return;
if (email === "") return;
await mutateAsync({
email,
organizationId: currentOrg.id
});
createNotification({
text: "Successfully updated invoice email recipient",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update invoice email recipient",
type: "error"
});
}
};
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<h2 className="mb-8 flex-1 text-xl font-semibold text-white">Invoice email recipient</h2>
<div className="max-w-md">
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input placeholder="jane@acme.com" {...field} className="bg-mineshaft-800" />
</FormControl>
)}
control={control}
name="email"
/>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Billing}>
{(isAllowed) => (
<Button
type="submit"
colorSchema="secondary"
isLoading={isLoading}
isDisabled={isLoading || !isAllowed}
>
Save
</Button>
)}
</OrgPermissionCan>
</form>
);
};

View File

@@ -0,0 +1,47 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useAddOrgPmtMethod } from "@app/hooks/api";
import { PmtMethodsTable } from "./PmtMethodsTable";
export const PmtMethodsSection = () => {
const { currentOrg } = useOrganization();
const { mutateAsync, isLoading } = useAddOrgPmtMethod();
const handleAddPmtMethodBtnClick = async () => {
if (!currentOrg?.id) return;
const url = await mutateAsync({
organizationId: currentOrg.id,
success_url: window.location.href,
cancel_url: window.location.href
});
window.location.href = url;
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-8 flex items-center">
<h2 className="flex-1 text-xl font-semibold text-white">Payment methods</h2>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Billing}>
{(isAllowed) => (
<Button
onClick={handleAddPmtMethodBtnClick}
colorSchema="secondary"
isLoading={isLoading}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed}
>
Add method
</Button>
)}
</OrgPermissionCan>
</div>
<PmtMethodsTable />
</div>
);
};

View File

@@ -0,0 +1,117 @@
import { faCreditCard, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
DeleteActionModal,
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteOrgPmtMethod, useGetOrgPmtMethods } from "@app/hooks/api";
export const PmtMethodsTable = () => {
const { currentOrg } = useOrganization();
const { data, isLoading } = useGetOrgPmtMethods(currentOrg?.id ?? "");
const deleteOrgPmtMethod = useDeleteOrgPmtMethod();
const { handlePopUpOpen, handlePopUpClose, handlePopUpToggle, popUp } = usePopUp([
"removeCard"
] as const);
const pmtMethodToRemove = popUp.removeCard.data as { id: string; last4: string } | undefined;
const handleDeletePmtMethodBtnClick = async () => {
if (!currentOrg?.id || !pmtMethodToRemove) return;
try {
await deleteOrgPmtMethod.mutateAsync({
organizationId: currentOrg.id,
pmtMethodId: pmtMethodToRemove.id
});
createNotification({
type: "success",
text: "Successfully removed payment method"
});
handlePopUpClose("removeCard");
} catch (error: any) {
createNotification({
type: "error",
text: error.message ?? "Error removing payment method"
});
}
};
return (
<>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="flex-1">Brand</Th>
<Th className="flex-1">Type</Th>
<Th className="flex-1">Last 4 Digits</Th>
<Th className="flex-1">Expiration</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{!isLoading &&
data &&
data?.length > 0 &&
data.map(({ _id: id, brand, exp_month, exp_year, funding, last4 }) => (
<Tr key={`pmt-method-${id}`} className="h-10">
<Td>{brand.charAt(0).toUpperCase() + brand.slice(1)}</Td>
<Td>{funding.charAt(0).toUpperCase() + funding.slice(1)}</Td>
<Td>{last4}</Td>
<Td>{`${exp_month}/${exp_year}`}</Td>
<Td>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Billing}
>
{(isAllowed) => (
<IconButton
onClick={() => handlePopUpOpen("removeCard", { id, last4 })}
size="lg"
isDisabled={!isAllowed}
colorSchema="danger"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</OrgPermissionCan>
</Td>
</Tr>
))}
{isLoading && <TableSkeleton columns={5} innerKey="pmt-methods" />}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No payment methods on file" icon={faCreditCard} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<DeleteActionModal
isOpen={popUp.removeCard.isOpen}
deleteKey="confirm"
onChange={(isOpen) => handlePopUpToggle("removeCard", isOpen)}
title={`Remove payment method ending in *${pmtMethodToRemove?.last4}?`}
onDeleteApproved={handleDeletePmtMethodBtnClick}
/>
</>
);
};

View File

@@ -0,0 +1,182 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useAddOrgTaxId } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const taxIDTypes = [
{ label: "Australia ABN", value: "au_abn" },
{ label: "Australia ARN", value: "au_arn" },
{ label: "Bulgaria UIC", value: "bg_uic" },
{ label: "Brazil CNPJ", value: "br_cnpj" },
{ label: "Brazil CPF", value: "br_cpf" },
{ label: "Canada BN", value: "ca_bn" },
{ label: "Canada GST/HST", value: "ca_gst_hst" },
{ label: "Canada PST BC", value: "ca_pst_bc" },
{ label: "Canada PST MB", value: "ca_pst_mb" },
{ label: "Canada PST SK", value: "ca_pst_sk" },
{ label: "Canada QST", value: "ca_qst" },
{ label: "Switzerland VAT", value: "ch_vat" },
{ label: "Chile TIN", value: "cl_tin" },
{ label: "Egypt TIN", value: "eg_tin" },
{ label: "Spain CIF", value: "es_cif" },
{ label: "EU OSS VAT", value: "eu_oss_vat" },
{ label: "EU VAT", value: "eu_vat" },
{ label: "GB VAT", value: "gb_vat" },
{ label: "Georgia VAT", value: "ge_vat" },
{ label: "Hong Kong BR", value: "hk_br" },
{ label: "Hungary TIN", value: "hu_tin" },
{ label: "Indonesia NPWP", value: "id_npwp" },
{ label: "Israel VAT", value: "il_vat" },
{ label: "India GST", value: "in_gst" },
{ label: "Iceland VAT", value: "is_vat" },
{ label: "Japan CN", value: "jp_cn" },
{ label: "Japan RN", value: "jp_rn" },
{ label: "Japan TRN", value: "jp_trn" },
{ label: "Kenya PIN", value: "ke_pin" },
{ label: "South Korea BRN", value: "kr_brn" },
{ label: "Liechtenstein UID", value: "li_uid" },
{ label: "Mexico RFC", value: "mx_rfc" },
{ label: "Malaysia FRP", value: "my_frp" },
{ label: "Malaysia ITN", value: "my_itn" },
{ label: "Malaysia SST", value: "my_sst" },
{ label: "Norway VAT", value: "no_vat" },
{ label: "New Zealand GST", value: "nz_gst" },
{ label: "Philippines TIN", value: "ph_tin" },
{ label: "Russia INN", value: "ru_inn" },
{ label: "Russia KPP", value: "ru_kpp" },
{ label: "Saudi Arabia VAT", value: "sa_vat" },
{ label: "Singapore GST", value: "sg_gst" },
{ label: "Singapore UEN", value: "sg_uen" },
{ label: "Slovenia TIN", value: "si_tin" },
{ label: "Thailand VAT", value: "th_vat" },
{ label: "Turkey TIN", value: "tr_tin" },
{ label: "Taiwan VAT", value: "tw_vat" },
{ label: "Ukraine VAT", value: "ua_vat" },
{ label: "US EIN", value: "us_ein" },
{ label: "South Africa VAT", value: "za_vat" }
];
const schema = z
.object({
type: z.string(),
value: z.string()
})
.required();
export type AddTaxIDFormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["addTaxID"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["addTaxID"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addTaxID"]>, state?: boolean) => void;
};
export const TaxIDModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => {
const { currentOrg } = useOrganization();
const addOrgTaxId = useAddOrgTaxId();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<AddTaxIDFormData>({
resolver: zodResolver(schema)
});
const onTaxIDModalSubmit = async ({ type, value }: AddTaxIDFormData) => {
try {
if (!currentOrg?.id) return;
await addOrgTaxId.mutateAsync({
organizationId: currentOrg.id,
type,
value
});
createNotification({
text: "Successfully added Tax ID",
type: "success"
});
handlePopUpClose("addTaxID");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to add Tax ID",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.addTaxID?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addTaxID", isOpen);
reset();
}}
>
<ModalContent title="Add Tax ID">
<form onSubmit={handleSubmit(onTaxIDModalSubmit)}>
<Controller
control={control}
name="type"
defaultValue="eu_vat"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Type" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{taxIDTypes.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="value"
render={({ field, fieldState: { error } }) => (
<FormControl label="Value" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="DE000000000" />
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Add
</Button>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,42 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { usePopUp } from "@app/hooks/usePopUp";
import { TaxIDModal } from "./TaxIDModal";
import { TaxIDTable } from "./TaxIDTable";
export const TaxIDSection = () => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addTaxID"
] as const);
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-8 flex items-center">
<h2 className="flex-1 text-xl font-semibold text-white">Tax ID</h2>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Billing}>
{(isAllowed) => (
<Button
onClick={() => handlePopUpOpen("addTaxID")}
colorSchema="secondary"
isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add method
</Button>
)}
</OrgPermissionCan>
</div>
<TaxIDTable />
<TaxIDModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
</div>
);
};

View File

@@ -0,0 +1,139 @@
import { faFileInvoice, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useDeleteOrgTaxId, useGetOrgTaxIds } from "@app/hooks/api";
const taxIDTypeLabelMap: { [key: string]: string } = {
au_abn: "Australia ABN",
au_arn: "Australia ARN",
bg_uic: "Bulgaria UIC",
br_cnpj: "Brazil CNPJ",
br_cpf: "Brazil CPF",
ca_bn: "Canada BN",
ca_gst_hst: "Canada GST/HST",
ca_pst_bc: "Canada PST BC",
ca_pst_mb: "Canada PST MB",
ca_pst_sk: "Canada PST SK",
ca_qst: "Canada QST",
ch_vat: "Switzerland VAT",
cl_tin: "Chile TIN",
eg_tin: "Egypt TIN",
es_cif: "Spain CIF",
eu_oss_vat: "EU OSS VAT",
eu_vat: "EU VAT",
gb_vat: "GB VAT",
ge_vat: "Georgia VAT",
hk_br: "Hong Kong BR",
hu_tin: "Hungary TIN",
id_npwp: "Indonesia NPWP",
il_vat: "Israel VAT",
in_gst: "India GST",
is_vat: "Iceland VAT",
jp_cn: "Japan CN",
jp_rn: "Japan RN",
jp_trn: "Japan TRN",
ke_pin: "Kenya PIN",
kr_brn: "South Korea BRN",
li_uid: "Liechtenstein UID",
mx_rfc: "Mexico RFC",
my_frp: "Malaysia FRP",
my_itn: "Malaysia ITN",
my_sst: "Malaysia SST",
no_vat: "Norway VAT",
nz_gst: "New Zealand GST",
ph_tin: "Philippines TIN",
ru_inn: "Russia INN",
ru_kpp: "Russia KPP",
sa_vat: "Saudi Arabia VAT",
sg_gst: "Singapore GST",
sg_uen: "Singapore UEN",
si_tin: "Slovenia TIN",
th_vat: "Thailand VAT",
tr_tin: "Turkey TIN",
tw_vat: "Taiwan VAT",
ua_vat: "Ukraine VAT",
us_ein: "US EIN",
za_vat: "South Africa VAT"
};
export const TaxIDTable = () => {
const { currentOrg } = useOrganization();
const { data, isLoading } = useGetOrgTaxIds(currentOrg?.id ?? "");
const deleteOrgTaxId = useDeleteOrgTaxId();
const handleDeleteTaxIdBtnClick = async (taxId: string) => {
if (!currentOrg?.id) return;
await deleteOrgTaxId.mutateAsync({
organizationId: currentOrg.id,
taxId
});
};
return (
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="flex-1">Type</Th>
<Th className="flex-1">Value</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{!isLoading &&
data &&
data?.length > 0 &&
data.map(({ id, type, value }) => (
<Tr key={`tax-id-${id}`} className="h-10">
<Td>{taxIDTypeLabelMap[type]}</Td>
<Td>{value}</Td>
<Td>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Billing}
>
{(isAllowed) => (
<IconButton
onClick={async () => {
await handleDeleteTaxIdBtnClick(id);
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</OrgPermissionCan>
</Td>
</Tr>
))}
{isLoading && <TableSkeleton columns={3} innerKey="tax-ids" />}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No Tax IDs on file" icon={faFileInvoice} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};

View File

@@ -0,0 +1 @@
export { BillingDetailsTab } from "./BillingDetailsTab";

View File

@@ -0,0 +1,10 @@
import { InvoicesTable } from "./InvoicesTable";
export const BillingReceiptsTab = () => {
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<h2 className="flex-1 text-xl font-semibold text-white">Invoices</h2>
<InvoicesTable />
</div>
);
};

View File

@@ -0,0 +1,80 @@
import { faDownload, faFileInvoice } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useGetOrgInvoices } from "@app/hooks/api";
export const InvoicesTable = () => {
const { currentOrg } = useOrganization();
const { data, isLoading } = useGetOrgInvoices(currentOrg?.id ?? "");
return (
<TableContainer className="mt-8">
<Table>
<THead>
<Tr>
<Th className="flex-1">Invoice #</Th>
<Th className="flex-1">Date</Th>
<Th className="flex-1">Status</Th>
<Th className="flex-1">Amount</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{!isLoading &&
data &&
data?.length > 0 &&
data.map(({ id, created, paid, number, total, invoice_pdf }) => {
const formattedTotal = (Math.floor(total) / 100).toLocaleString("en-US", {
style: "currency",
currency: "USD"
});
const createdDate = new Date(created * 1000);
const day: number = createdDate.getDate();
const month: number = createdDate.getMonth() + 1;
const year: number = createdDate.getFullYear();
const formattedDate: string = `${day}/${month}/${year}`;
return (
<Tr key={`invoice-${id}`} className="h-10">
<Td>{number}</Td>
<Td>{formattedDate}</Td>
<Td>{paid ? "Paid" : "Not Paid"}</Td>
<Td>{formattedTotal}</Td>
<Td>
<IconButton
onClick={async () => window.open(invoice_pdf)}
size="lg"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faDownload} />
</IconButton>
</Td>
</Tr>
);
})}
{isLoading && <TableSkeleton columns={5} innerKey="invoices" />}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No invoices on file" icon={faFileInvoice} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};

View File

@@ -0,0 +1 @@
export { BillingReceiptsTab } from "./BillingReceiptsTab";

View File

@@ -0,0 +1,9 @@
import { LicensesSection } from "./LicensesSection";
export const BillingSelfHostedTab = () => {
return (
<div>
<LicensesSection />
</div>
);
};

View File

@@ -0,0 +1,63 @@
import { faFileContract } from "@fortawesome/free-solid-svg-icons";
import {
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useGetOrgLicenses } from "@app/hooks/api";
export const LicensesSection = () => {
const { currentOrg } = useOrganization();
const { data, isLoading } = useGetOrgLicenses(currentOrg?.id ?? "");
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<h2 className="mb-8 flex-1 text-xl font-semibold text-white">Enterprise licenses</h2>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>License Key</Th>
<Th>Status</Th>
<Th>Issued Date</Th>
<Th>Expiry Date</Th>
</Tr>
</THead>
<TBody>
{!isLoading &&
data &&
data?.length > 0 &&
data.map(({ id, licenseKey, isActivated, createdAt, expiresAt }) => {
const formattedCreatedAt = new Date(createdAt).toISOString().split("T")[0];
const formattedExpiresAt = new Date(expiresAt).toISOString().split("T")[0];
return (
<Tr key={`license-${id}`} className="h-10">
<Td>{licenseKey}</Td>
<Td>{isActivated ? "Active" : "Inactive"}</Td>
<Td>{formattedCreatedAt}</Td>
<Td>{formattedExpiresAt}</Td>
</Tr>
);
})}
{isLoading && <TableSkeleton columns={4} innerKey="licenses" />}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={4}>
<EmptyState title="No enterprise licenses on file" icon={faFileContract} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
</div>
);
};

View File

@@ -0,0 +1,42 @@
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { withPermission } from "@app/hoc";
import { BillingCloudTab } from "../BillingCloudTab";
import { BillingDetailsTab } from "../BillingDetailsTab";
import { BillingReceiptsTab } from "../BillingReceiptsTab";
import { BillingSelfHostedTab } from "../BillingSelfHostedTab";
const tabs = [
{ name: "Infisical Cloud", key: "tab-infisical-cloud" },
{ name: "Infisical Self-Hosted", key: "tab-infisical-self-hosted" },
{ name: "Receipts", key: "tab-receipts" },
{ name: "Billing details", key: "tab-billing-details" }
];
export const BillingTabGroup = withPermission(
() => {
return (
<Tabs defaultValue={tabs[0].key}>
<TabList>
{tabs.map((tab) => (
<Tab value={tab.key}>{tab.name}</Tab>
))}
</TabList>
<TabPanel value={tabs[0].key}>
<BillingCloudTab />
</TabPanel>
<TabPanel value={tabs[1].key}>
<BillingSelfHostedTab />
</TabPanel>
<TabPanel value={tabs[2].key}>
<BillingReceiptsTab />
</TabPanel>
<TabPanel value={tabs[3].key}>
<BillingDetailsTab />
</TabPanel>
</Tabs>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Billing }
);

View File

@@ -0,0 +1 @@
export { BillingTabGroup } from "./BillingTabGroup";

View File

@@ -0,0 +1 @@
export { BillingTabGroup } from "./BillingTabGroup";

View File

@@ -0,0 +1,52 @@
import { Helmet } from 'react-helmet'
import { useTranslation } from 'react-i18next'
import { createFileRoute } from '@tanstack/react-router'
import { OrgPermissionActions, OrgPermissionSubjects } from '@app/context'
import { withPermission } from '@app/hoc'
import { BillingTabGroup } from './-components'
const BillngPage = withPermission(
() => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl px-6">
<div className="my-6">
<p className="text-3xl font-semibold text-gray-200">
{t('billing.title')}
</p>
<div />
</div>
<BillingTabGroup />
</div>
</div>
)
},
{
action: OrgPermissionActions.Read,
subject: OrgPermissionSubjects.Billing,
},
)
const BillingRoute = () => {
const { t } = useTranslation()
return (
<div className="h-full bg-bunker-800">
<Helmet>
<title>{t('common.head-title', { title: t('billing.title') })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
</Helmet>
<BillngPage />
</div>
)
}
export const Route = createFileRoute(
'/_authenticate/_org_details/_org-layout/organization/$organizationId/billing/',
)({
component: BillingRoute,
})

View File

@@ -0,0 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { ProductOverview } from "../secret-manager/overview";
const CertManagerOverviewPage = () => <ProductOverview type={ProjectType.CertificateManager} />;
export const Route = createFileRoute(
"/_authenticate/_org_details/_org-layout/organization/$organizationId/cert-manager/overview"
)({
component: CertManagerOverviewPage
});

View File

@@ -0,0 +1,172 @@
import { useState } from "react";
import { faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
EmptyState,
Input,
Modal,
ModalContent,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { useDebounce, useResetPageHelper } from "@app/hooks";
import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api";
import { EFilterReturnedUsers } from "@app/hooks/api/groups/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["addGroupMembers"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addGroupMembers"]>, state?: boolean) => void;
};
export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [searchMemberFilter, setSearchMemberFilter] = useState("");
const [debouncedSearch] = useDebounce(searchMemberFilter);
const popUpData = popUp?.addGroupMembers?.data as {
groupId: string;
slug: string;
};
const offset = (page - 1) * perPage;
const { data, isLoading } = useListGroupUsers({
id: popUpData?.groupId,
groupSlug: popUpData?.slug,
offset,
limit: perPage,
search: debouncedSearch,
filter: EFilterReturnedUsers.NON_MEMBERS
});
const { totalCount = 0 } = data ?? {};
useResetPageHelper({
totalCount,
offset,
setPage
});
const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup();
const handleAddMember = async (username: string) => {
try {
if (!popUpData?.slug) {
createNotification({
text: "Some data is missing, please refresh the page and try again",
type: "error"
});
return;
}
await addUserToGroupMutateAsync({
groupId: popUpData.groupId,
username,
slug: popUpData.slug
});
createNotification({
text: "Successfully assigned user to the group",
type: "success"
});
} catch {
createNotification({
text: "Failed to assign user to the group",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.addGroupMembers?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addGroupMembers", isOpen);
}}
>
<ModalContent title="Add Group Members">
<Input
value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>User</Th>
<Th />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={2} innerKey="group-users" />}
{!isLoading &&
data?.users?.map(({ id, firstName, lastName, username }) => {
return (
<Tr className="items-center" key={`group-user-${id}`}>
<Td>
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
<p>{username}</p>
</Td>
<Td className="flex justify-end">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => {
return (
<Button
isLoading={isLoading}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="submit"
onClick={() => handleAddMember(username)}
>
Assign
</Button>
);
}}
</OrgPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isLoading && !data?.users?.length && (
<EmptyState
title={
debouncedSearch ? "No users match search" : "All users are already in the group"
}
icon={faUsers}
/>
)}
</TableContainer>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,192 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Input,
Modal,
ModalContent
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { findOrgMembershipRole } from "@app/helpers/roles";
import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const GroupFormSchema = z.object({
name: z.string().min(1, "Name cannot be empty").max(50, "Name must be 50 characters or fewer"),
slug: z
.string()
.min(5, "Slug must be at least 5 characters long")
.max(36, "Slug must be 36 characters or fewer"),
role: z.object({ name: z.string(), slug: z.string() })
});
export type TGroupFormData = z.infer<typeof GroupFormSchema>;
type Props = {
popUp: UsePopUpState<["groupCreateUpdate"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["groupCreateUpdate"]>) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["groupCreateUpdate"]>,
state?: boolean
) => void;
};
export const GroupCreateUpdateModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => {
const { currentOrg } = useOrganization();
const { data: roles } = useGetOrgRoles(currentOrg?.id || "");
const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateGroup();
const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateGroup();
const { control, handleSubmit, reset } = useForm<TGroupFormData>({
resolver: zodResolver(GroupFormSchema)
});
useEffect(() => {
const group = popUp?.groupCreateUpdate?.data as {
groupId: string;
name: string;
slug: string;
role: string;
customRole: {
name: string;
slug: string;
};
};
if (!roles?.length) return;
if (group) {
reset({
name: group.name,
slug: group.slug,
role: group?.customRole ?? findOrgMembershipRole(roles, group.role)
});
} else {
reset({
name: "",
slug: "",
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
});
}
}, [popUp?.groupCreateUpdate?.data, roles]);
const onGroupModalSubmit = async ({ name, slug, role }: TGroupFormData) => {
try {
if (!currentOrg?.id) return;
const group = popUp?.groupCreateUpdate?.data as {
groupId: string;
name: string;
slug: string;
};
if (group) {
await updateMutateAsync({
id: group.groupId,
name,
slug,
role: role.slug || undefined
});
} else {
await createMutateAsync({
name,
slug,
organizationId: currentOrg.id,
role: role.slug || undefined
});
}
handlePopUpToggle("groupCreateUpdate", false);
reset();
createNotification({
text: `Successfully ${popUp?.groupCreateUpdate?.data ? "updated" : "created"} group`,
type: "success"
});
} catch {
createNotification({
text: `Failed to ${popUp?.groupCreateUpdate?.data ? "updated" : "created"} group`,
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.groupCreateUpdate?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("groupCreateUpdate", isOpen);
reset();
}}
>
<ModalContent
bodyClassName="overflow-visible"
title={`${popUp?.groupCreateUpdate?.data ? "Update" : "Create"} Group`}
>
<form onSubmit={handleSubmit(onGroupModalSubmit)}>
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Name" errorText={error?.message} isError={Boolean(error)}>
<Input {...field} placeholder="Engineering" />
</FormControl>
)}
/>
<Controller
control={control}
name="slug"
render={({ field, fieldState: { error } }) => (
<FormControl label="Slug" errorText={error?.message} isError={Boolean(error)}>
<Input {...field} placeholder="engineering" />
</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
options={roles}
placeholder="Select role..."
onChange={onChange}
value={value}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={createIsLoading || updateIsLoading}
>
{!popUp?.groupCreateUpdate?.data ? "Create" : "Update"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("groupCreateUpdate")}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,88 @@
import { faPencil } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import { IconButton, Spinner, Tooltip } from "@app/components/v2";
import { CopyButton } from "@app/components/v2/CopyButton";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { useGetGroupById } from "@app/hooks/api/";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
groupId: string;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["groupCreateUpdate"]>, data?: object) => void;
};
export const GroupDetailsSection = ({ groupId, handlePopUpOpen }: Props) => {
const { data, isLoading } = useGetGroupById(groupId);
if (isLoading) return <Spinner size="sm" className="ml-2 mt-2" />;
return data ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Group Details</h3>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => {
return (
<Tooltip content="Edit Group">
<IconButton
isDisabled={!isAllowed}
ariaLabel="edit group button"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("groupCreateUpdate", {
groupId,
name: data.group.name,
slug: data.group.slug,
role: data.group.role
});
}}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
</Tooltip>
);
}}
</OrgPermissionCan>
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Group ID</p>
<div className="group flex items-center gap-2">
<p className="text-sm text-mineshaft-300">{data.group.id}</p>
<CopyButton value={data.group.id} name="Group ID" size="xs" variant="plain" />
</div>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">{data.group.name}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Slug</p>
<div className="group flex items-center gap-2">
<p className="text-sm text-mineshaft-300">{data.group.slug}</p>
<CopyButton value={data.group.slug} name="Slug" size="xs" variant="plain" />
</div>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Organization Role</p>
<p className="text-sm text-mineshaft-300">{data.group.role}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Created At</p>
<p className="text-sm text-mineshaft-300">
{new Date(data.group.createdAt).toLocaleString()}
</p>
</div>
</div>
</div>
) : (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<p className="text-mineshaft-300">Group data not found</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,90 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal, IconButton } from "@app/components/v2";
import { useRemoveUserFromGroup } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { AddGroupMembersModal } from "../AddGroupMemberModal";
import { GroupMembersTable } from "./GroupMembersTable";
type Props = {
groupId: string;
groupSlug: string;
};
export const GroupMembersSection = ({ groupId, groupSlug }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"addGroupMembers",
"removeMemberFromGroup"
] as const);
const { mutateAsync: removeUserFromGroupMutateAsync } = useRemoveUserFromGroup();
const handleRemoveUserFromGroup = async (username: string) => {
try {
await removeUserFromGroupMutateAsync({
groupId,
username,
slug: groupSlug
});
createNotification({
text: `Successfully removed user ${username} from the group`,
type: "success"
});
handlePopUpToggle("removeMemberFromGroup", false);
} catch {
createNotification({
text: `Failed to remove user ${username} from the group`,
type: "error"
});
}
};
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Group Members</h3>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("addGroupMembers", {
groupId,
slug: groupSlug
});
}}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
</div>
<div className="py-4">
<GroupMembersTable
groupId={groupId}
groupSlug={groupSlug}
handlePopUpOpen={handlePopUpOpen}
/>
</div>
<AddGroupMembersModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.removeMemberFromGroup.isOpen}
title={`Are you sure want to remove ${
(popUp?.removeMemberFromGroup?.data as { username: string })?.username || ""
} from the group?`}
onChange={(isOpen) => handlePopUpToggle("removeMemberFromGroup", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => {
const userData = popUp?.removeMemberFromGroup?.data as {
username: string;
id: string;
};
return handleRemoveUserFromGroup(userData.username);
}}
/>
</div>
);
};

View File

@@ -0,0 +1,195 @@
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faFolder,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useListGroupUsers } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { EFilterReturnedUsers } from "@app/hooks/api/groups/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { GroupMembershipRow } from "./GroupMembershipRow";
type Props = {
groupId: string;
groupSlug: string;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeMemberFromGroup", "addGroupMembers"]>,
data?: object
) => void;
};
enum GroupMembersOrderBy {
Name = "name"
}
export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props) => {
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection
} = usePagination(GroupMembersOrderBy.Name, { initPerPage: 10 });
const { data: groupMemberships, isLoading } = useListGroupUsers({
id: groupId,
groupSlug,
offset,
limit: perPage,
search,
filter: EFilterReturnedUsers.EXISTING_MEMBERS
});
const filteredGroupMemberships = useMemo(() => {
return groupMemberships && groupMemberships?.users
? groupMemberships?.users
?.filter((membership) => {
const userSearchString = `${membership.firstName && membership.firstName} ${
membership.lastName && membership.lastName
} ${membership.email && membership.email} ${
membership.username && membership.username
}`;
return userSearchString.toLowerCase().includes(search.trim().toLowerCase());
})
.sort((a, b) => {
const [membershipOne, membershipTwo] =
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
const membershipOneComparisonString = membershipOne.firstName
? membershipOne.firstName
: membershipOne.email;
const membershipTwoComparisonString = membershipTwo.firstName
? membershipTwo.firstName
: membershipTwo.email;
const comparison = membershipOneComparisonString
.toLowerCase()
.localeCompare(membershipTwoComparisonString.toLowerCase());
return comparison;
})
: [];
}, [groupMemberships, orderDirection, search]);
useResetPageHelper({
totalCount: filteredGroupMemberships?.length,
offset,
setPage
});
return (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search users..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
<Th>Email</Th>
<Th>Added On</Th>
<Th />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="group-user-memberships" />}
{!isLoading &&
filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => {
return (
<GroupMembershipRow
key={`user-group-membership-${userGroupMembership.id}`}
user={userGroupMembership}
handlePopUpOpen={handlePopUpOpen}
/>
);
})}
</TBody>
</Table>
{Boolean(filteredGroupMemberships.length) && (
<Pagination
count={filteredGroupMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredGroupMemberships?.length && (
<EmptyState
title={
groupMemberships?.users.length
? "No users match this search..."
: "This group does not have any members yet"
}
icon={groupMemberships?.users.length ? faSearch : faFolder}
/>
)}
{!groupMemberships?.users.length && (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => (
<div className="mb-4 flex items-center justify-center">
<Button
isDisabled={!isAllowed}
onClick={() => {
handlePopUpOpen("addGroupMembers", {
groupId,
slug: groupSlug
});
}}
>
Add members
</Button>
</div>
)}
</OrgPermissionCan>
)}
</TableContainer>
</div>
);
};

View File

@@ -0,0 +1,56 @@
import { faUserMinus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import { IconButton, Td, Tooltip, Tr } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { TGroupUser } from "@app/hooks/api/groups/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
user: TGroupUser;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeMemberFromGroup"]>,
data?: object
) => void;
};
export const GroupMembershipRow = ({
user: { firstName, lastName, username, joinedGroupAt, email, id },
handlePopUpOpen
}: Props) => {
return (
<Tr className="items-center" key={`group-user-${id}`}>
<Td>
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
</Td>
<Td>
<p>{email}</p>
</Td>
<Td>
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
<p>{new Date(joinedGroupAt).toLocaleDateString()}</p>
</Tooltip>
</Td>
<Td className="justify-end">
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => {
return (
<Tooltip content="Remove user from group">
<IconButton
isDisabled={!isAllowed}
ariaLabel="Remove user from group"
onClick={() => handlePopUpOpen("removeMemberFromGroup", { username })}
variant="plain"
colorSchema="danger"
>
<FontAwesomeIcon icon={faUserMinus} className="cursor-pointer" />
</IconButton>
</Tooltip>
);
}}
</OrgPermissionCan>
</Td>
</Tr>
);
};

View File

@@ -0,0 +1 @@
export { GroupMembersSection } from "./GroupMembersSection";

View File

@@ -0,0 +1 @@
export { GroupDetailsSection } from "./GroupDetailsSection";

View File

@@ -0,0 +1,245 @@
import { Helmet } from 'react-helmet'
import { useTranslation } from 'react-i18next'
import { faChevronLeft, faEllipsis } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { twMerge } from 'tailwind-merge'
import { createNotification } from '@app/components/notifications'
import { OrgPermissionCan } from '@app/components/permissions'
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Spinner,
Tooltip,
UpgradePlanModal,
} from '@app/components/v2'
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
} from '@app/context'
import { withPermission } from '@app/hoc'
import { useDeleteGroup } from '@app/hooks/api'
import { useGetGroupById } from '@app/hooks/api/groups/queries'
import { usePopUp } from '@app/hooks/usePopUp'
import { GroupCreateUpdateModal } from './-components/GroupCreateUpdateModal'
import { GroupMembersSection } from './-components/GroupMembersSection'
import { GroupDetailsSection } from './-components'
export enum TabSections {
Member = 'members',
Groups = 'groups',
Roles = 'roles',
Identities = 'identities',
}
const GroupPage = withPermission(
() => {
const navigate = useNavigate()
const params = useParams({
from: '/_authenticate/_org_details/_org-layout/organization/$organizationId/groups/$groupId',
})
const groupId = params.groupId as string
const { currentOrg } = useOrganization()
const { data, isLoading } = useGetGroupById(groupId)
const { mutateAsync: deleteMutateAsync } = useDeleteGroup()
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } =
usePopUp(['groupCreateUpdate', 'deleteGroup', 'upgradePlan'] as const)
const onDeleteGroupSubmit = async ({
name,
id,
}: {
name: string
id: string
}) => {
try {
await deleteMutateAsync({
id,
})
createNotification({
text: `Successfully deleted the ${name} group`,
type: 'success',
})
navigate({
to: '/organization/$organizationId/members' as const,
params: {
organizationId: currentOrg.id,
},
search: {
selectedTab: TabSections.Groups,
},
})
} catch (err) {
console.error(err)
createNotification({
text: `Failed to delete the ${name} group`,
type: 'error',
})
}
handlePopUpClose('deleteGroup')
}
if (isLoading) return <Spinner size="sm" className="ml-2 mt-2" />
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<div className="mx-auto mb-6 w-full max-w-7xl px-6 py-6">
<Button
variant="link"
type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() => {
navigate({
to: '/organization/$organizationId/members' as const,
params: {
organizationId: currentOrg.id,
},
search: {
selectedTab: TabSections.Groups,
},
})
}}
className="mb-4"
>
Groups
</Button>
<div className="mb-4 flex items-center justify-between">
<p className="text-3xl font-semibold text-white">
{data.group.name}
</p>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
'pointer-events-none cursor-not-allowed opacity-50',
)}
onClick={async () => {
handlePopUpOpen('groupCreateUpdate', {
groupId,
name: data.group.name,
slug: data.group.slug,
role: data.group.role,
})
}}
disabled={!isAllowed}
>
Edit Group
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? 'hover:!bg-red-500 hover:!text-white'
: 'pointer-events-none cursor-not-allowed opacity-50',
)}
onClick={async () => {
handlePopUpOpen('deleteGroup', {
id: groupId,
name: data.group.name,
})
}}
disabled={!isAllowed}
>
Delete Group
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex">
<div className="mr-4 w-96">
<GroupDetailsSection
groupId={groupId}
handlePopUpOpen={handlePopUpOpen}
/>
</div>
<GroupMembersSection
groupId={groupId}
groupSlug={data.group.slug}
/>
</div>
</div>
)}
<GroupCreateUpdateModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteGroup.isOpen}
title={`Are you sure want to delete the group named ${
(popUp?.deleteGroup?.data as { name: string })?.name || ''
}?`}
onChange={(isOpen) => handlePopUpToggle('deleteGroup', isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onDeleteGroupSubmit(
popUp?.deleteGroup?.data as { name: string; id: string },
)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle('upgradePlan', isOpen)}
text={
(popUp.upgradePlan?.data as { description: string })?.description
}
/>
</div>
)
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Groups },
)
const GroupDetailPage = () => {
const { t } = useTranslation()
return (
<>
<Helmet>
<title>
{t('common.head-title', { title: t('settings.org.title') })}
</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<GroupPage />
</>
)
}
export const Route = createFileRoute(
'/_authenticate/_org_details/_org-layout/organization/$organizationId/groups/$groupId/',
)({
component: GroupDetailPage,
})

View File

@@ -0,0 +1,157 @@
import { useEffect } from "react";
import { faPencil, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, IconButton, Select, SelectItem, Tooltip } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { useGetIdentityById } from "@app/hooks/api";
import { IdentityAuthMethod, identityAuthToNameMap } from "@app/hooks/api/identities";
import { Identity } from "@app/hooks/api/identities/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { IdentityClientSecrets } from "./IdentityClientSecrets";
import { IdentityTokens } from "./IdentityTokens";
type Props = {
identityId: string;
setSelectedAuthMethod: (authMethod: Identity["authMethods"][number] | null) => void;
selectedAuthMethod: Identity["authMethods"][number] | null;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<
[
"clientSecret",
"identityAuthMethod",
"revokeClientSecret",
"token",
"revokeToken",
"universalAuthClientSecret",
"tokenList"
]
>,
data?: object
) => void;
};
export const IdentityAuthenticationSection = ({
identityId,
setSelectedAuthMethod,
selectedAuthMethod,
handlePopUpOpen
}: Props) => {
const { data } = useGetIdentityById(identityId);
useEffect(() => {
if (!data?.identity) return;
if (data.identity.authMethods?.length) {
setSelectedAuthMethod(data.identity.authMethods[0]);
}
// eslint-disable-next-line consistent-return
return () => setSelectedAuthMethod(null);
}, [data?.identity]);
return data ? (
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Authentication</h3>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Identity}>
{(isAllowed) => {
return (
<Tooltip content="Add new auth method">
<IconButton
isDisabled={!isAllowed}
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() =>
handlePopUpOpen("identityAuthMethod", {
identityId,
name: data.identity.name,
allAuthMethods: data.identity.authMethods
})
}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
</Tooltip>
);
}}
</OrgPermissionCan>
</div>
{data.identity.authMethods.length > 0 ? (
<>
<div className="py-4">
<div className="flex justify-between">
<p className="mb-0.5 ml-px text-sm font-semibold text-mineshaft-300">Auth Method</p>
</div>
<div className="flex items-center gap-2">
<div className="w-full">
<Select
className="w-full"
value={selectedAuthMethod as string}
onValueChange={(value) => setSelectedAuthMethod(value as IdentityAuthMethod)}
>
{(data.identity?.authMethods || []).map((authMethod) => (
<SelectItem key={authMethod || authMethod} value={authMethod}>
{identityAuthToNameMap[authMethod]}
</SelectItem>
))}
</Select>
</div>
<div>
<Tooltip content="Edit auth method">
<IconButton
onClick={() => {
handlePopUpOpen("identityAuthMethod", {
identityId,
name: data.identity.name,
authMethod: selectedAuthMethod,
allAuthMethods: data.identity.authMethods
});
}}
ariaLabel="copy icon"
variant="plain"
className="group relative"
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
</Tooltip>{" "}
</div>
</div>
</div>
{selectedAuthMethod === IdentityAuthMethod.UNIVERSAL_AUTH && (
<IdentityClientSecrets identityId={identityId} handlePopUpOpen={handlePopUpOpen} />
)}
{selectedAuthMethod === IdentityAuthMethod.TOKEN_AUTH && (
<IdentityTokens identityId={identityId} handlePopUpOpen={handlePopUpOpen} />
)}
</>
) : (
<div className="w-full space-y-2 pt-2">
<p className="text-sm text-mineshaft-300">
No authentication methods configured. Get started by creating a new auth method.
</p>
<Button
onClick={() => {
handlePopUpOpen("identityAuthMethod", {
identityId,
name: data.identity.name,
allAuthMethods: data.identity.authMethods
});
}}
variant="outline_bg"
className="w-full"
size="xs"
>
Create Auth Method
</Button>
</div>
)}
</div>
) : (
<div />
);
};

View File

@@ -0,0 +1,142 @@
import { faCheck, faCopy, faKey, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, IconButton, Tooltip } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { useTimedReset } from "@app/hooks";
import {
useGetIdentityById,
useGetIdentityUniversalAuth,
useGetIdentityUniversalAuthClientSecrets
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
identityId: string;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<
["clientSecret", "revokeClientSecret", "universalAuthClientSecret"]
>,
data?: object
) => void;
};
const SHOW_LIMIT = 3;
export const IdentityClientSecrets = ({ identityId, handlePopUpOpen }: Props) => {
const [copyTextClientId, isCopyingClientId, setCopyTextClientId] = useTimedReset<string>({
initialState: "Copy Client ID to clipboard"
});
const { data } = useGetIdentityById(identityId);
const { data: identityUniversalAuth } = useGetIdentityUniversalAuth(identityId);
const { data: clientSecrets } = useGetIdentityUniversalAuthClientSecrets(identityId);
return (
<div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Client ID</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">{identityUniversalAuth?.clientId ?? ""}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextClientId}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText(identityUniversalAuth?.clientId ?? "");
setCopyTextClientId("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingClientId ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
</div>
</div>
{clientSecrets?.length ? (
<div className="flex justify-between">
<p className="text-sm font-semibold text-mineshaft-300">{`Client Secrets (${clientSecrets.length})`}</p>
<Button
variant="link"
onClick={() => {
handlePopUpOpen("universalAuthClientSecret", {
identityId,
name: data?.identity.name ?? ""
});
}}
>
Manage
</Button>
</div>
) : (
<div />
)}
{clientSecrets
?.slice(0, SHOW_LIMIT)
.map(({ id, clientSecretTTL, clientSecretPrefix, createdAt }) => {
let expiresAt;
if (clientSecretTTL > 0) {
expiresAt = new Date(new Date(createdAt).getTime() + clientSecretTTL * 1000);
}
return (
<div
className="group flex items-center justify-between py-2 last:pb-0"
key={`client-secret-${id}`}
>
<div className="flex items-center">
<FontAwesomeIcon size="1x" icon={faKey} />
<div className="ml-4">
<p className="text-sm font-semibold text-mineshaft-300">
{`${clientSecretPrefix}****`}
</p>
<p className="text-sm text-mineshaft-300">
{expiresAt ? `Expires on ${format(expiresAt, "yyyy-MM-dd")}` : "No Expiry"}
</p>
</div>
</div>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content="Revoke Client Secret">
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("revokeClientSecret", {
clientSecretId: id,
clientSecretPrefix
});
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
</div>
);
})}
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Identity}>
{(isAllowed) => {
return (
<Button
isDisabled={!isAllowed}
className="mt-4 w-full"
colorSchema="primary"
type="submit"
onClick={() => {
handlePopUpOpen("clientSecret", {
identityId
});
}}
>
Create Client Secret
</Button>
);
}}
</OrgPermissionCan>
</div>
);
};

View File

@@ -0,0 +1,121 @@
import { faEllipsis, faKey } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip
} from "@app/components/v2";
import { useGetIdentityById, useGetIdentityTokensTokenAuth } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
identityId: string;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["token", "tokenList", "revokeToken"]>,
data?: object
) => void;
};
export const IdentityTokens = ({ identityId, handlePopUpOpen }: Props) => {
const { data } = useGetIdentityById(identityId);
const { data: tokens } = useGetIdentityTokensTokenAuth(identityId);
return (
<div>
{tokens?.length ? (
<div className="flex justify-between">
<p className="text-sm font-semibold text-mineshaft-300">{`Access Tokens (${tokens.length})`}</p>
<Button
variant="link"
onClick={() => {
handlePopUpOpen("tokenList", {
identityId,
name: data?.identity.name ?? ""
});
}}
>
Manage
</Button>
</div>
) : (
<div />
)}
{tokens?.map((token) => {
const expiresAt = new Date(
new Date(token.createdAt).getTime() + token.accessTokenMaxTTL * 1000
);
return (
<div
className="group flex items-center justify-between py-2 last:pb-0"
key={`identity-token-${token.id}`}
>
<div className="flex items-center">
<FontAwesomeIcon size="1x" icon={faKey} />
<div className="ml-4">
<p className="text-sm font-semibold text-mineshaft-300">
{token.name ? token.name : "-"}
</p>
<p className="text-sm text-mineshaft-300">
{token.isAccessTokenRevoked
? "Revoked"
: `Expires on ${format(expiresAt, "yyyy-MM-dd")}`}
</p>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="opacity-0 transition-opacity duration-300 hover:text-primary-400 group-hover:opacity-100 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuItem
onClick={async () => {
handlePopUpOpen("token", {
identityId,
tokenId: token.id,
name: token.name
});
}}
>
Edit Token
</DropdownMenuItem>
{!token.isAccessTokenRevoked && (
<DropdownMenuItem
onClick={async () => {
handlePopUpOpen("revokeToken", {
identityId,
tokenId: token.id,
name: token.name
});
}}
>
Revoke Token
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
<Button
className="mr-4 mt-4 w-full"
colorSchema="primary"
type="submit"
onClick={() => {
handlePopUpOpen("token", {
identityId
});
}}
>
Create Token
</Button>
</div>
);
};

View File

@@ -0,0 +1 @@
export { IdentityAuthenticationSection } from "./IdentityAuthenticationSection";

View File

@@ -0,0 +1,182 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Tooltip
} from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
import { useCreateIdentityUniversalAuthClientSecret } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
.object({
description: z.string(),
ttl: z.string().refine((val) => Number(val) <= 315360000, {
message: "TTL cannot be greater than 315360000"
}),
numUsesLimit: z.string()
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["clientSecret"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["clientSecret"]>, state?: boolean) => void;
};
export const IdentityClientSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
const { mutateAsync: createClientSecret } = useCreateIdentityUniversalAuthClientSecret();
const [token, setToken] = useState("");
const [copyTextToken, isCopyingToken, setCopyTextToken] = useTimedReset<string>({
initialState: "Copy to clipboard"
});
const hasToken = Boolean(token);
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
description: "",
ttl: "",
numUsesLimit: ""
}
});
const popUpData = popUp?.clientSecret?.data as {
identityId: string;
};
const onFormSubmit = async ({ description, ttl, numUsesLimit }: FormData) => {
const { clientSecret } = await createClientSecret({
identityId: popUpData.identityId,
description,
ttl: Number(ttl),
numUsesLimit: Number(numUsesLimit)
});
setToken(clientSecret);
createNotification({
text: "Successfully created client secret",
type: "success"
});
reset();
};
return (
<Modal
isOpen={popUp?.clientSecret?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("clientSecret", isOpen);
reset();
setToken("");
}}
>
<ModalContent
title="Create Client Secret"
subTitle={hasToken ? "We will only show this secret once" : ""}
>
{!hasToken ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue=""
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Description"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="My Client Secret" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="TTL (seconds - optional)"
isError={Boolean(error)}
errorText={error?.message}
>
<div className="flex">
<Input {...field} placeholder="0" type="number" min="0" step="1" />
</div>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="numUsesLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Max Number of Uses"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="0" type="number" min="0" step="1" />
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("clientSecret", false)}
>
Cancel
</Button>
</div>
</form>
) : (
<div className="mb-3 mr-2 mt-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{token}</p>
<Tooltip content={copyTextToken}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(token);
setCopyTextToken("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingToken ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
)}
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,118 @@
import { faCheck, faCopy, faKey, faPencil } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import { IconButton, Tag, Tooltip } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { useTimedReset } from "@app/hooks";
import { useGetIdentityById } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
identityId: string;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["identity", "identityAuthMethod", "token", "clientSecret"]>,
data?: object
) => void;
};
export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) => {
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
const { data } = useGetIdentityById(identityId);
return data ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Identity Details</h3>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Identity}>
{(isAllowed) => {
return (
<Tooltip content="Edit Identity">
<IconButton
isDisabled={!isAllowed}
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("identity", {
identityId,
name: data.identity.name,
role: data.role,
customRole: data.customRole,
metadata: data.metadata
});
}}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
</Tooltip>
);
}}
</OrgPermissionCan>
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Identity ID</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">{data.identity.id}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextId}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText(data.identity.id);
setCopyTextId("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">{data.identity.name}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Organization Role</p>
<p className="text-sm text-mineshaft-300">{data.role}</p>
</div>
<div>
<p className="text-sm font-semibold text-mineshaft-300">Metadata</p>
{data?.metadata?.length ? (
<div className="mt-1 flex flex-wrap gap-2 text-sm text-mineshaft-300">
{data.metadata?.map((el) => (
<div key={el.id} className="flex items-center">
<Tag
size="xs"
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
>
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
<div>{el.key}</div>
</Tag>
<Tag
size="xs"
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
>
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.value}
</div>
</Tag>
</div>
))}
</div>
) : (
<p className="text-sm text-mineshaft-300">-</p>
)}
</div>
</div>
</div>
) : (
<div />
);
};

View File

@@ -0,0 +1,185 @@
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Modal,
ModalClose,
ModalContent
} from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import {
useAddIdentityToWorkspace,
useGetIdentityProjectMemberships,
useGetProjectRoles,
useGetWorkspaceById
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
.object({
project: z.object({ name: z.string(), id: z.string() }),
role: z.object({ name: z.string(), slug: z.string() })
})
.required();
type FormData = z.infer<typeof schema>;
type Props = {
identityId: string;
popUp: UsePopUpState<["addIdentityToProject"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["addIdentityToProject"]>,
state?: boolean
) => void;
};
// TODO: eventually refactor to support adding to multiple projects at once? would lose role granularity unique to project
const Content = ({ identityId, handlePopUpToggle }: Omit<Props, "popUp">) => {
const { currentOrg } = useOrganization();
const { workspaces } = useWorkspace();
const { mutateAsync: addIdentityToWorkspace } = useAddIdentityToWorkspace();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting },
watch
} = useForm<FormData>({
resolver: zodResolver(schema)
});
const projectId = watch("project")?.id;
const { data: projectMemberships } = useGetIdentityProjectMemberships(identityId);
const { data: project, isLoading: isProjectLoading } = useGetWorkspaceById(projectId);
const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(project?.id ?? "");
const filteredWorkspaces = useMemo(() => {
const wsWorkspaceIds = new Map();
projectMemberships?.forEach((projectMembership) => {
wsWorkspaceIds.set(projectMembership.project.id, true);
});
return (workspaces || []).filter(
({ id, orgId }) => !wsWorkspaceIds.has(id) && orgId === currentOrg?.id
);
}, [workspaces, projectMemberships]);
const onFormSubmit = async ({ project: selectedProject, role }: FormData) => {
try {
await addIdentityToWorkspace({
workspaceId: selectedProject.id,
identityId,
role: role.slug || undefined
});
createNotification({
text: "Successfully added identity to project",
type: "success"
});
reset();
handlePopUpToggle("addIdentityToProject", false);
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to add identity to project";
createNotification({
text,
type: "error"
});
}
};
const isProjectSelected = Boolean(projectId);
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="project"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Project"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<FilterableSelect
value={value}
onChange={onChange}
options={filteredWorkspaces}
placeholder="Select project..."
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
isLoading={isProjectSelected && isProjectLoading}
/>
</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
isDisabled={!isProjectSelected}
value={value}
onChange={onChange}
options={roles}
isLoading={isProjectSelected && isRolesLoading}
placeholder="Select role..."
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Add
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
);
};
export const IdentityAddToProjectModal = ({ identityId, popUp, handlePopUpToggle }: Props) => {
return (
<Modal
isOpen={popUp?.addIdentityToProject?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addIdentityToProject", isOpen);
}}
>
<ModalContent bodyClassName="overflow-visible" title="Add Identity to Project">
<Content identityId={identityId} handlePopUpToggle={handlePopUpToggle} />
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,111 @@
import { useMemo } from "react";
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { format } from "date-fns";
import { createNotification } from "@app/components/notifications";
import { IconButton, Tag, Td, Tooltip, Tr } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { IdentityMembership } from "@app/hooks/api/identities/types";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
export enum TabSections {
Member = "members",
Groups = "groups",
Roles = "roles",
Identities = "identities"
}
type Props = {
membership: IdentityMembership;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeIdentityFromProject"]>,
data?: object
) => void;
};
const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Admin) return "Admin";
if (role === ProjectMembershipRole.Member) return "Developer";
if (role === ProjectMembershipRole.Viewer) return "Viewer";
if (role === ProjectMembershipRole.NoAccess) return "No Access";
return role;
};
export const IdentityProjectRow = ({
membership: { id, createdAt, identity, project, roles },
handlePopUpOpen
}: Props) => {
const { workspaces } = useWorkspace();
const navigate = useNavigate();
const isAccessible = useMemo(() => {
const workspaceIds = new Map();
workspaces?.forEach((workspace) => {
workspaceIds.set(workspace.id, true);
});
return workspaceIds.has(project.id);
}, [workspaces, project]);
return (
<Tr
className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`identity-project-membership-${id}`}
onClick={() => {
if (isAccessible) {
navigate({
to: `/${project?.type}/${project.id}/members` as const,
search: {
selectedTab: TabSections.Identities
}
});
return;
}
createNotification({
text: "Unable to access project",
type: "error"
});
}}
>
<Td className="max-w-0 truncate">{project.name}</Td>
<Td>
<Tag size="xs">{project.type}</Tag>
</Td>
<Td>{`${formatRoleName(roles[0].role, roles[0].customRoleName)}${
roles.length > 1 ? ` (+${roles.length - 1})` : ""
}`}</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td>
{isAccessible && (
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content="Remove">
<IconButton
colorSchema="danger"
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("removeIdentityFromProject", {
identityId: identity.id,
identityName: identity.name,
projectId: project.id,
projectName: project.name
});
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
)}
</Td>
</Tr>
);
};

View File

@@ -0,0 +1,92 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal, IconButton } from "@app/components/v2";
import { useDeleteIdentityFromWorkspace } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { IdentityAddToProjectModal } from "./IdentityAddToProjectModal";
import { IdentityProjectsTable } from "./IdentityProjectsTable";
type Props = {
identityId: string;
};
export const IdentityProjectsSection = ({ identityId }: Props) => {
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addIdentityToProject",
"removeIdentityFromProject"
] as const);
const onRemoveIdentitySubmit = async (id: string, projectId: string) => {
try {
await deleteMutateAsync({
identityId: id,
workspaceId: projectId
});
createNotification({
text: "Successfully removed identity from project",
type: "success"
});
handlePopUpClose("removeIdentityFromProject");
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to remove identity from project";
createNotification({
text,
type: "error"
});
}
};
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Projects</h3>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("addIdentityToProject");
}}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
</div>
<div className="py-4">
<IdentityProjectsTable identityId={identityId} handlePopUpOpen={handlePopUpOpen} />
</div>
<DeleteActionModal
isOpen={popUp.removeIdentityFromProject.isOpen}
title={`Are you sure want to remove ${
(popUp?.removeIdentityFromProject?.data as { identityName: string })?.identityName || ""
} from ${
(popUp?.removeIdentityFromProject?.data as { projectName: string })?.projectName || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("removeIdentityFromProject", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => {
const popupData = popUp?.removeIdentityFromProject?.data as {
identityId: string;
projectId: string;
};
return onRemoveIdentitySubmit(popupData.identityId, popupData.projectId);
}}
/>
<IdentityAddToProjectModal
identityId={identityId}
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
/>
</div>
);
};

View File

@@ -0,0 +1,151 @@
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faFolder,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetIdentityProjectMemberships } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { IdentityProjectRow } from "./IdentityProjectRow";
type Props = {
identityId: string;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeIdentityFromProject"]>,
data?: object
) => void;
};
enum IdentityProjectsOrderBy {
Name = "name"
}
export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => {
const { data: projectMemberships = [], isLoading } = useGetIdentityProjectMemberships(identityId);
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection
} = usePagination(IdentityProjectsOrderBy.Name, { initPerPage: 10 });
const filteredProjectMemberships = useMemo(
() =>
projectMemberships
?.filter((membership) =>
membership.project.name.toLowerCase().includes(search.trim().toLowerCase())
)
.sort((a, b) => {
const [membershipOne, membershipTwo] =
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
return membershipOne.project.name
.toLowerCase()
.localeCompare(membershipTwo.project.name.toLowerCase());
}),
[projectMemberships, orderDirection, search]
);
useResetPageHelper({
totalCount: filteredProjectMemberships.length,
offset,
setPage
});
return (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search projects..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-2/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
<Th>Type</Th>
<Th>Role</Th>
<Th>Added On</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="identity-project-memberships" />}
{!isLoading &&
filteredProjectMemberships.slice(offset, perPage * page).map((membership) => {
return (
<IdentityProjectRow
key={`identity-project-membership-${membership.id}`}
membership={membership}
handlePopUpOpen={handlePopUpOpen}
/>
);
})}
</TBody>
</Table>
{Boolean(filteredProjectMemberships.length) && (
<Pagination
count={filteredProjectMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredProjectMemberships?.length && (
<EmptyState
title={
projectMemberships.length
? "No projects match search..."
: "This identity has not been assigned to any projects"
}
icon={projectMemberships.length ? faSearch : faFolder}
/>
)}
</TableContainer>
</div>
);
};

View File

@@ -0,0 +1 @@
export { IdentityProjectsSection } from "./IdentityProjectsSection";

View File

@@ -0,0 +1,270 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { faCheck, faCopy, faKey, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
EmptyState,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useToggle } from "@app/hooks";
import {
useCreateTokenIdentityTokenAuth,
useGetIdentityTokensTokenAuth,
useGetIdentityUniversalAuthClientSecrets
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
name: z.string()
});
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["tokenList", "revokeToken"]>;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["revokeToken"]>, data?: object) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["tokenList", "revokeToken"]>,
state?: boolean
) => void;
};
export const IdentityTokenListModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => {
const { t } = useTranslation();
const [token, setToken] = useState("");
const [isClientSecretCopied, setIsClientSecretCopied] = useToggle(false);
const [isClientIdCopied, setIsClientIdCopied] = useToggle(false);
const popUpData = popUp?.tokenList?.data as {
identityId: string;
name: string;
};
const { data: tokens } = useGetIdentityTokensTokenAuth(popUpData?.identityId ?? "");
const { data, isLoading } = useGetIdentityUniversalAuthClientSecrets(popUpData?.identityId ?? "");
const { mutateAsync: createToken } = useCreateTokenIdentityTokenAuth();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: ""
}
});
useEffect(() => {
let timer: NodeJS.Timeout;
if (isClientSecretCopied) {
timer = setTimeout(() => setIsClientSecretCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isClientSecretCopied]);
useEffect(() => {
let timer: NodeJS.Timeout;
if (isClientIdCopied) {
timer = setTimeout(() => setIsClientIdCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isClientIdCopied]);
const onFormSubmit = async ({ name }: FormData) => {
try {
if (!popUpData?.identityId) return;
const newTokenData = await createToken({
identityId: popUpData.identityId,
name
});
setToken(newTokenData.accessToken);
createNotification({
text: "Successfully created token",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create token",
type: "error"
});
}
};
const hasToken = Boolean(token);
return (
<Modal
isOpen={popUp?.tokenList?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("tokenList", isOpen);
reset();
setToken("");
}}
>
<ModalContent title={`Manage Access Tokens for ${popUpData?.name ?? ""}`}>
<h2 className="mb-4">New Token</h2>
{hasToken ? (
<div>
<div className="mb-4 flex items-center justify-between">
<p>We will only show this token once</p>
<Button
colorSchema="secondary"
type="submit"
onClick={() => {
reset();
setToken("");
}}
>
Got it
</Button>
</div>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{token}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(token);
setIsClientSecretCopied.on();
}}
>
<FontAwesomeIcon icon={isClientSecretCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
</div>
) : (
<form onSubmit={handleSubmit(onFormSubmit)} className="mb-8">
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
<div className="flex">
<Input {...field} placeholder="My Token" />
<Button
className="ml-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create
</Button>
</div>
</FormControl>
)}
/>
</form>
)}
<h2 className="mb-4">Tokens</h2>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>name</Th>
<Th>Num Uses</Th>
<Th>Created At</Th>
<Th>Max Expires At</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={5} innerKey="identities-tokens" />}
{!isLoading &&
tokens?.map(
({
id,
createdAt,
name,
accessTokenNumUses,
accessTokenNumUsesLimit,
accessTokenMaxTTL,
isAccessTokenRevoked
}) => {
const expiresAt = new Date(
new Date(createdAt).getTime() + accessTokenMaxTTL * 1000
);
return (
<Tr className="h-10 items-center" key={`mi-client-secret-${id}`}>
<Td>{name === "" ? "-" : name}</Td>
<Td>{`${accessTokenNumUses}${
accessTokenNumUsesLimit ? `/${accessTokenNumUsesLimit}` : ""
}`}</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td>
{isAccessTokenRevoked ? "Revoked" : `${format(expiresAt, "yyyy-MM-dd")}`}
</Td>
<Td>
{!isAccessTokenRevoked && (
<IconButton
onClick={() => {
handlePopUpOpen("revokeToken", {
identityId: popUpData?.identityId,
tokenId: id,
name
});
}}
size="lg"
colorSchema="primary"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</Td>
</Tr>
);
}
)}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState
title="No tokens have been created for this identity yet"
icon={faKey}
/>
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,182 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Tooltip
} from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
import { useCreateTokenIdentityTokenAuth, useUpdateIdentityTokenAuthToken } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
.object({
name: z.string()
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["token"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["token"]>, state?: boolean) => void;
};
export const IdentityTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
const { mutateAsync: createToken } = useCreateTokenIdentityTokenAuth();
const { mutateAsync: updateToken } = useUpdateIdentityTokenAuthToken();
const [token, setToken] = useState("");
const [copyTextToken, isCopyingToken, setCopyTextToken] = useTimedReset<string>({
initialState: "Copy to clipboard"
});
const hasToken = Boolean(token);
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: ""
}
});
const tokenData = popUp?.token?.data as {
identityId: string;
tokenId?: string;
name?: string;
};
useEffect(() => {
if (tokenData?.tokenId && tokenData?.name) {
reset({
name: tokenData.name
});
} else {
reset({
name: ""
});
}
}, [popUp?.token?.data]);
const onFormSubmit = async ({ name }: FormData) => {
try {
if (tokenData?.tokenId) {
// update
await updateToken({
identityId: tokenData.identityId,
tokenId: tokenData.tokenId,
name
});
handlePopUpToggle("token", false);
} else {
// create
const newTokenData = await createToken({
identityId: tokenData.identityId,
name
});
setToken(newTokenData.accessToken);
}
createNotification({
text: `Successfully ${popUp?.token?.data ? "updated" : "created"} token`,
type: "success"
});
reset();
} catch (err) {
console.error(err);
const error = err as any;
const text =
error?.response?.data?.message ??
`Failed to ${popUp?.token?.data ? "update" : "create"} token`;
createNotification({
text,
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.token?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("token", isOpen);
reset();
setToken("");
}}
>
<ModalContent
title={`${tokenData?.tokenId ? "Update" : "Create"} Access Token`}
subTitle={hasToken ? "We will only show this token once" : ""}
>
{!hasToken ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="My Token" />
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{tokenData?.name ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("token", false)}
>
Cancel
</Button>
</div>
</form>
) : (
<div className="mb-3 mr-2 mt-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{token}</p>
<Tooltip content={copyTextToken}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(token);
setCopyTextToken("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingToken ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
)}
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,6 @@
export { IdentityAuthenticationSection } from "./IdentityAuthenticationSection/IdentityAuthenticationSection";
export { IdentityClientSecretModal } from "./IdentityClientSecretModal";
export { IdentityDetailsSection } from "./IdentityDetailsSection";
export { IdentityProjectsSection } from "./IdentityProjectsSection/IdentityProjectsSection";
export { IdentityTokenListModal } from "./IdentityTokenListModal";
export { IdentityTokenModal } from "./IdentityTokenModal";

View File

@@ -0,0 +1,415 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useState } from 'react'
import { Helmet } from 'react-helmet'
import { useTranslation } from 'react-i18next'
import { faChevronLeft, faEllipsis } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { twMerge } from 'tailwind-merge'
import { createNotification } from '@app/components/notifications'
import { OrgPermissionCan } from '@app/components/permissions'
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip,
UpgradePlanModal,
} from '@app/components/v2'
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
} from '@app/context'
import { withPermission } from '@app/hoc'
import {
IdentityAuthMethod,
useDeleteIdentity,
useGetIdentityById,
useRevokeIdentityTokenAuthToken,
useRevokeIdentityUniversalAuthClientSecret,
} from '@app/hooks/api'
import { Identity } from '@app/hooks/api/identities/types'
import { usePopUp } from '@app/hooks/usePopUp'
import { TabSections } from '@app/types/org'
import { IdentityAuthMethodModal } from '../../members/-components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal'
import { IdentityModal } from '../../members/-components/OrgIdentityTab/components/IdentitySection/IdentityModal'
import { IdentityUniversalAuthClientSecretModal } from '../../members/-components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthClientSecretModal'
import {
IdentityAuthenticationSection,
IdentityClientSecretModal,
IdentityDetailsSection,
IdentityProjectsSection,
IdentityTokenListModal,
IdentityTokenModal,
} from './-components'
export const IdentitySection = withPermission(
() => {
const navigate = useNavigate()
const params = useParams({
from: '/_authenticate/_org_details/_org-layout/organization/$organizationId/identities/$identityId',
})
const identityId = params.identityId as string
const { currentOrg } = useOrganization()
const orgId = currentOrg?.id || ''
const { data } = useGetIdentityById(identityId)
const { mutateAsync: deleteIdentity } = useDeleteIdentity()
const { mutateAsync: revokeToken } = useRevokeIdentityTokenAuthToken()
const { mutateAsync: revokeClientSecret } =
useRevokeIdentityUniversalAuthClientSecret()
const [selectedAuthMethod, setSelectedAuthMethod] = useState<
Identity['authMethods'][number] | null
>(null)
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } =
usePopUp([
'identity',
'deleteIdentity',
'identityAuthMethod',
'revokeAuthMethod',
'token',
'tokenList',
'revokeToken',
'clientSecret',
'revokeClientSecret',
'universalAuthClientSecret', // list of client secrets
'upgradePlan',
] as const)
const onDeleteIdentitySubmit = async (id: string) => {
try {
await deleteIdentity({
identityId: id,
organizationId: orgId,
})
createNotification({
text: 'Successfully deleted identity',
type: 'success',
})
handlePopUpClose('deleteIdentity')
navigate({
to: `/organization/${orgId}/members` as const,
search: {
selectedTab: TabSections.Identities,
},
})
} catch (err) {
console.error(err)
const error = err as any
const text =
error?.response?.data?.message ?? 'Failed to delete identity'
createNotification({
text,
type: 'error',
})
}
}
const onRevokeTokenSubmit = async ({
identityId: parentIdentityId,
tokenId,
name,
}: {
identityId: string
tokenId: string
name: string
}) => {
try {
await revokeToken({
identityId: parentIdentityId,
tokenId,
})
handlePopUpClose('revokeToken')
createNotification({
text: `Successfully revoked token ${name ?? ''}`,
type: 'success',
})
} catch (err) {
console.error(err)
const error = err as any
const text =
error?.response?.data?.message ?? 'Failed to delete identity'
createNotification({
text,
type: 'error',
})
}
}
const onDeleteClientSecretSubmit = async ({
clientSecretId,
}: {
clientSecretId: string
}) => {
try {
if (
!data?.identity.id ||
selectedAuthMethod !== IdentityAuthMethod.UNIVERSAL_AUTH
)
return
await revokeClientSecret({
identityId: data?.identity.id,
clientSecretId,
})
handlePopUpToggle('revokeClientSecret', false)
createNotification({
text: 'Successfully deleted client secret',
type: 'success',
})
} catch (err) {
console.error(err)
createNotification({
text: 'Failed to delete client secret',
type: 'error',
})
}
}
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<div className="mx-auto mb-6 w-full max-w-7xl px-6 py-6">
<Button
variant="link"
type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() => {
navigate({
to: `/organization/${orgId}/members` as const,
search: {
selectedTab: TabSections.Identities,
},
})
}}
className="mb-4"
>
Identities
</Button>
<div className="mb-4 flex items-center justify-between">
<p className="text-3xl font-semibold text-white">
{data.identity.name}
</p>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
'pointer-events-none cursor-not-allowed opacity-50',
)}
onClick={async () => {
handlePopUpOpen('identity', {
identityId,
name: data.identity.name,
role: data.role,
customRole: data.customRole,
})
}}
disabled={!isAllowed}
>
Edit Identity
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
'pointer-events-none cursor-not-allowed opacity-50',
)}
onClick={async () => {
handlePopUpOpen('identityAuthMethod', {
identityId,
name: data.identity.name,
allAuthMethods: data.identity.authMethods,
})
}}
disabled={!isAllowed}
>
Add new auth method
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? 'hover:!bg-red-500 hover:!text-white'
: 'pointer-events-none cursor-not-allowed opacity-50',
)}
onClick={async () => {
handlePopUpOpen('deleteIdentity', {
identityId,
name: data.identity.name,
})
}}
disabled={!isAllowed}
>
Delete Identity
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex">
<div className="mr-4 w-96">
<IdentityDetailsSection
identityId={identityId}
handlePopUpOpen={handlePopUpOpen}
/>
<IdentityAuthenticationSection
selectedAuthMethod={selectedAuthMethod}
setSelectedAuthMethod={setSelectedAuthMethod}
identityId={identityId}
handlePopUpOpen={handlePopUpOpen}
/>
</div>
<IdentityProjectsSection identityId={identityId} />
</div>
</div>
)}
<IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<IdentityAuthMethodModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
<IdentityTokenModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
/>
<IdentityTokenListModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
<IdentityClientSecretModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
/>
<IdentityUniversalAuthClientSecretModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle('upgradePlan', isOpen)}
text={
(popUp.upgradePlan?.data as { description: string })?.description
}
/>
<DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen}
title={`Are you sure want to delete ${
(popUp?.deleteIdentity?.data as { name: string })?.name || ''
}?`}
onChange={(isOpen) => handlePopUpToggle('deleteIdentity', isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onDeleteIdentitySubmit(
(popUp?.deleteIdentity?.data as { identityId: string })
?.identityId,
)
}
/>
<DeleteActionModal
isOpen={popUp.revokeToken.isOpen}
title={`Are you sure want to revoke ${
(popUp?.revokeToken?.data as { name: string })?.name || ''
}?`}
onChange={(isOpen) => handlePopUpToggle('revokeToken', isOpen)}
deleteKey="confirm"
onDeleteApproved={() => {
const revokeTokenData = popUp?.revokeToken?.data as {
identityId: string
tokenId: string
name: string
}
return onRevokeTokenSubmit(revokeTokenData)
}}
/>
<DeleteActionModal
isOpen={popUp.revokeClientSecret.isOpen}
title={`Are you sure want to delete the client secret ${
(popUp?.revokeClientSecret?.data as { clientSecretPrefix: string })
?.clientSecretPrefix || ''
}************?`}
onChange={(isOpen) => handlePopUpToggle('revokeClientSecret', isOpen)}
deleteKey="confirm"
onDeleteApproved={() => {
const deleteClientSecretData = popUp?.revokeClientSecret?.data as {
clientSecretId: string
clientSecretPrefix: string
}
return onDeleteClientSecretSubmit({
clientSecretId: deleteClientSecretData.clientSecretId,
})
}}
/>
</div>
)
},
{
action: OrgPermissionActions.Read,
subject: OrgPermissionSubjects.Identity,
},
)
const IdentityDetailPage = () => {
const { t } = useTranslation()
return (
<>
<Helmet>
<title>
{t('common.head-title', { title: t('settings.org.title') })}
</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<IdentitySection />
</>
)
}
export const Route = createFileRoute(
'/_authenticate/_org_details/_org-layout/organization/$organizationId/identities/$identityId/',
)({
component: IdentityDetailPage,
})

View File

@@ -1,38 +0,0 @@
import {
createFileRoute,
useRouteContext,
useRouter,
} from '@tanstack/react-router'
function RouteComponent() {
const user = useRouteContext({
from: '/_authenticate',
select: (el) => el.user,
})
const router = useRouter()
return (
<div>
Hello {user?.email}!
<button
type="button"
onClick={() => {
router.invalidate({
filter: (d) => {
console.log(d)
return true
},
})
}}
>
Click
</button>
</div>
)
}
export const Route = createFileRoute(
'/_authenticate/_org_details/_org-layout/organization/$organizationId/',
)({
component: RouteComponent,
})

View File

@@ -0,0 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import { ProjectType } from '@app/hooks/api/workspace/types'
import { ProductOverview } from '../secret-manager/overview'
const KeyManagerOverviewPage = () => <ProductOverview type={ProjectType.KMS} />
export const Route = createFileRoute(
'/_authenticate/_org_details/_org-layout/organization/$organizationId/kms/overview',
)({
component: KeyManagerOverviewPage,
})

View File

@@ -0,0 +1,18 @@
import { motion } from "framer-motion";
import { OrgGroupsSection } from "./components";
export const OrgGroupsTab = () => {
return (
<motion.div
key="panel-org-groups"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<OrgGroupsSection />
</motion.div>
);
};

View File

@@ -0,0 +1,189 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Input,
Modal,
ModalContent
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { findOrgMembershipRole } from "@app/helpers/roles";
import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const GroupFormSchema = z.object({
name: z.string().min(1, "Name cannot be empty").max(50, "Name must be 50 characters or fewer"),
slug: z
.string()
.min(5, "Slug must be at least 5 characters long")
.max(36, "Slug must be 36 characters or fewer"),
role: z.object({ name: z.string(), slug: z.string() })
});
export type TGroupFormData = z.infer<typeof GroupFormSchema>;
type Props = {
popUp: UsePopUpState<["group"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["group"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void;
};
export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => {
const { currentOrg } = useOrganization();
const { data: roles } = useGetOrgRoles(currentOrg?.id || "");
const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateGroup();
const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateGroup();
const { control, handleSubmit, reset } = useForm<TGroupFormData>({
resolver: zodResolver(GroupFormSchema)
});
useEffect(() => {
const group = popUp?.group?.data as {
groupId: string;
name: string;
slug: string;
role: string;
customRole: {
name: string;
slug: string;
};
};
if (!roles?.length) return;
if (group) {
reset({
name: group.name,
slug: group.slug,
role: group?.customRole ?? findOrgMembershipRole(roles, group.role)
});
} else {
reset({
name: "",
slug: "",
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
});
}
}, [popUp?.group?.data, roles]);
const onGroupModalSubmit = async ({ name, slug, role }: TGroupFormData) => {
try {
if (!currentOrg?.id) return;
const group = popUp?.group?.data as {
groupId: string;
name: string;
slug: string;
};
if (group) {
await updateMutateAsync({
id: group.groupId,
name,
slug,
role: role.slug || undefined
});
} else {
await createMutateAsync({
name,
slug,
organizationId: currentOrg.id,
role: role.slug || undefined
});
}
handlePopUpToggle("group", false);
reset();
createNotification({
text: `Successfully ${popUp?.group?.data ? "updated" : "created"} group`,
type: "success"
});
} catch {
createNotification({
text: `Failed to ${popUp?.group?.data ? "updated" : "created"} group`,
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.group?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("group", isOpen);
reset();
}}
>
<ModalContent
bodyClassName="overflow-visible"
title={`${popUp?.group?.data ? "Update" : "Create"} Group`}
>
<form onSubmit={handleSubmit(onGroupModalSubmit)}>
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Name" errorText={error?.message} isError={Boolean(error)}>
<Input {...field} placeholder="Engineering" />
</FormControl>
)}
/>
<Controller
control={control}
name="slug"
render={({ field, fieldState: { error } }) => (
<FormControl label="Slug" errorText={error?.message} isError={Boolean(error)}>
<Input {...field} placeholder="engineering" />
</FormControl>
)}
/>
<Controller
control={control}
name="role"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label={`${popUp?.group?.data ? "Update" : ""} Role`}
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<FilterableSelect
options={roles}
placeholder="Select role..."
onChange={onChange}
value={value}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={createIsLoading || updateIsLoading}
>
{!popUp?.group?.data ? "Create" : "Update"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("group")}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,98 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
import { useDeleteGroup } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { OrgGroupModal } from "./OrgGroupModal";
import { OrgGroupsTable } from "./OrgGroupsTable";
export const OrgGroupsSection = () => {
const { subscription } = useSubscription();
const { mutateAsync: deleteMutateAsync } = useDeleteGroup();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"group",
"groupMembers",
"deleteGroup",
"upgradePlan"
] as const);
const handleAddGroupModal = () => {
if (!subscription?.groups) {
handlePopUpOpen("upgradePlan", {
description:
"You can manage users more efficiently with groups if you upgrade your Infisical plan."
});
} else {
handlePopUpOpen("group");
}
};
const onDeleteGroupSubmit = async ({ name, groupId }: { name: string; groupId: string }) => {
try {
await deleteMutateAsync({
id: groupId
});
createNotification({
text: `Successfully deleted the group named ${name}`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to delete the group named ${name}`,
type: "error"
});
}
handlePopUpClose("deleteGroup");
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Groups</p>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handleAddGroupModal()}
isDisabled={!isAllowed}
>
Create Group
</Button>
)}
</OrgPermissionCan>
</div>
<OrgGroupsTable handlePopUpOpen={handlePopUpOpen} />
<OrgGroupModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteGroup.isOpen}
title={`Are you sure want to delete the group named ${
(popUp?.deleteGroup?.data as { name: string })?.name || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteGroup", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onDeleteGroupSubmit(popUp?.deleteGroup?.data as { name: string; groupId: string })
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
);
};

View File

@@ -0,0 +1,397 @@
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faEllipsis,
faMagnifyingGlass,
faSearch,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Select,
SelectItem,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetOrganizationGroups, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { useNavigate } from "@tanstack/react-router";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["group", "deleteGroup", "groupMembers"]>,
data?: {
groupId?: string;
name?: string;
slug?: string;
role?: string;
customRole?: {
name: string;
slug: string;
};
}
) => void;
};
enum GroupsOrderBy {
Name = "name",
Slug = "slug",
Role = "role"
}
export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
const navigate = useNavigate();
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { isLoading, data: groups = [] } = useGetOrganizationGroups(orgId);
const { mutateAsync: updateMutateAsync } = useUpdateGroup();
const { data: roles } = useGetOrgRoles(orgId);
const handleChangeRole = async ({ id, role }: { id: string; role: string }) => {
try {
await updateMutateAsync({
id,
role
});
createNotification({
text: "Successfully updated group role",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update group role",
type: "error"
});
}
};
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
orderBy,
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<GroupsOrderBy>(GroupsOrderBy.Name, { initPerPage: 20 });
const filteredGroups = useMemo(() => {
const filtered = search
? groups?.filter(
({ name, slug }) =>
name.toLowerCase().includes(search.toLowerCase()) ||
slug.toLowerCase().includes(search.toLowerCase())
)
: groups;
const ordered = filtered?.sort((a, b) => {
switch (orderBy) {
case GroupsOrderBy.Role: {
const aValue = a.role === "custom" ? (a.customRole?.name as string) : a.role;
const bValue = b.role === "custom" ? (b.customRole?.name as string) : b.role;
return aValue.toLowerCase().localeCompare(bValue.toLowerCase());
}
default:
return a[orderBy].toLowerCase().localeCompare(b[orderBy].toLowerCase());
}
});
return orderDirection === OrderByDirection.ASC ? ordered : ordered?.reverse();
}, [search, groups, orderBy, orderDirection]);
const handleSort = (column: GroupsOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
useResetPageHelper({
totalCount: filteredGroups.length,
offset,
setPage
});
return (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search groups..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={`ml-2 ${orderBy === GroupsOrderBy.Name ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(GroupsOrderBy.Name)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC && orderBy === GroupsOrderBy.Name
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Slug
<IconButton
variant="plain"
className={`ml-2 ${orderBy === GroupsOrderBy.Slug ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(GroupsOrderBy.Slug)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC && orderBy === GroupsOrderBy.Slug
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Role
<IconButton
variant="plain"
className={`ml-2 ${orderBy === GroupsOrderBy.Role ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(GroupsOrderBy.Role)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC && orderBy === GroupsOrderBy.Role
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="org-groups" />}
{!isLoading &&
filteredGroups
.slice(offset, perPage * page)
.map(({ id, name, slug, role, customRole }) => {
return (
<Tr
onClick={() =>
navigate({
to: "/organization/$organizationId/groups/$groupId",
params: {
organizationId: currentOrg.id,
groupId: id
}
})
}
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`org-group-${id}`}
>
<Td>{name}</Td>
<Td>{slug}</Td>
<Td>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => {
return (
<Select
value={role === "custom" ? (customRole?.slug as string) : role}
isDisabled={!isAllowed}
className="w-48 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
handleChangeRole({
id,
role: selectedRole
})
}
>
{(roles || []).map(({ slug: roleSlug, name: roleName }) => (
<SelectItem value={roleSlug} key={`role-option-${roleSlug}`}>
{roleName}
</SelectItem>
))}
</Select>
);
}}
</OrgPermissionCan>
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
createNotification({
text: "Copied group ID to clipboard",
type: "info"
});
navigator.clipboard.writeText(id);
}}
>
Copy Group ID
</DropdownMenuItem>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("group", {
groupId: id,
name,
slug,
role,
customRole
});
}}
disabled={!isAllowed}
>
Edit Group
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() =>
navigate({
to: "/organization/$organizationId/groups/$groupId",
params: {
organizationId: currentOrg.id,
groupId: id
}
})
}
disabled={!isAllowed}
>
Manage Members
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteGroup", {
groupId: id,
name
});
}}
disabled={!isAllowed}
>
Delete Group
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{Boolean(filteredGroups.length) && (
<Pagination
count={filteredGroups.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredGroups?.length && (
<EmptyState
title={
groups.length
? "No organization groups match search..."
: "No organization groups found"
}
icon={groups.length ? faSearch : faUsers}
/>
)}
</TableContainer>
</div>
);
};

View File

@@ -0,0 +1 @@
export { OrgGroupsSection } from "./OrgGroupsSection";

View File

@@ -0,0 +1 @@
export { OrgGroupsTab } from "./OrgGroupsTab";

View File

@@ -0,0 +1,17 @@
import { motion } from "framer-motion";
import { IdentitySection } from "./components";
export const OrgIdentityTab = () => {
return (
<motion.div
key="panel-service-token"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<IdentitySection />
</motion.div>
);
};

View File

@@ -0,0 +1,55 @@
import { useState } from "react";
import { Modal, ModalContent } from "@app/components/v2";
import { IdentityAuthMethod, identityAuthToNameMap } from "@app/hooks/api/identities";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { IdentityAuthMethodModalContent } from "./IdentityAuthMethodModalContent";
type Props = {
popUp: UsePopUpState<["identityAuthMethod", "upgradePlan", "revokeAuthMethod"]>;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["identityAuthMethod", "upgradePlan", "revokeAuthMethod"]>,
state?: boolean
) => void;
};
export const IdentityAuthMethodModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => {
const [selectedAuthMethod, setSelectedAuthMethod] = useState<IdentityAuthMethod | null>(null);
const initialAuthMethod = popUp?.identityAuthMethod?.data?.authMethod;
const isSelectedAuthAlreadyConfigured =
popUp?.identityAuthMethod?.data?.allAuthMethods?.includes(selectedAuthMethod);
return (
<Modal
isOpen={popUp?.identityAuthMethod?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("identityAuthMethod", isOpen);
}}
>
<ModalContent
title={
isSelectedAuthAlreadyConfigured
? `Edit ${identityAuthToNameMap[selectedAuthMethod!] ?? ""}`
: `Create new ${identityAuthToNameMap[selectedAuthMethod!] ?? ""}`
}
>
<IdentityAuthMethodModalContent
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
identity={{
name: popUp?.identityAuthMethod?.data?.name,
authMethods: popUp?.identityAuthMethod?.data?.allAuthMethods,
id: popUp?.identityAuthMethod.data?.identityId
}}
initialAuthMethod={initialAuthMethod}
setSelectedAuthMethod={setSelectedAuthMethod}
/>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,335 @@
import { useCallback } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Badge,
DeleteActionModal,
FormControl,
Select,
SelectItem,
Tooltip,
UpgradePlanModal
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
useDeleteIdentityAwsAuth,
useDeleteIdentityAzureAuth,
useDeleteIdentityGcpAuth,
useDeleteIdentityKubernetesAuth,
useDeleteIdentityOidcAuth,
useDeleteIdentityTokenAuth,
useDeleteIdentityUniversalAuth
} from "@app/hooks/api";
import {
IdentityAuthMethod,
identityAuthToNameMap,
useDeleteIdentityJwtAuth
} from "@app/hooks/api/identities";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { IdentityAwsAuthForm } from "./IdentityAwsAuthForm";
import { IdentityAzureAuthForm } from "./IdentityAzureAuthForm";
import { IdentityGcpAuthForm } from "./IdentityGcpAuthForm";
import { IdentityJwtAuthForm } from "./IdentityJwtAuthForm";
import { IdentityKubernetesAuthForm } from "./IdentityKubernetesAuthForm";
import { IdentityOidcAuthForm } from "./IdentityOidcAuthForm";
import { IdentityTokenAuthForm } from "./IdentityTokenAuthForm";
import { IdentityUniversalAuthForm } from "./IdentityUniversalAuthForm";
type Props = {
popUp: UsePopUpState<["identityAuthMethod", "upgradePlan", "revokeAuthMethod"]>;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["identityAuthMethod", "upgradePlan", "revokeAuthMethod"]>,
state?: boolean
) => void;
identity: {
name: string;
id: string;
authMethods: IdentityAuthMethod[];
};
initialAuthMethod: IdentityAuthMethod;
setSelectedAuthMethod: (authMethod: IdentityAuthMethod) => void;
};
type TRevokeOptions = {
identityId: string;
organizationId: string;
};
type TRevokeMethods = {
revokeMethod: (revokeOptions: TRevokeOptions) => Promise<any>;
render: () => JSX.Element;
};
const identityAuthMethods = [
{ label: "Token Auth", value: IdentityAuthMethod.TOKEN_AUTH },
{ label: "Universal Auth", value: IdentityAuthMethod.UNIVERSAL_AUTH },
{ label: "Kubernetes Auth", value: IdentityAuthMethod.KUBERNETES_AUTH },
{ label: "GCP Auth", value: IdentityAuthMethod.GCP_AUTH },
{ label: "AWS Auth", value: IdentityAuthMethod.AWS_AUTH },
{ label: "Azure Auth", value: IdentityAuthMethod.AZURE_AUTH },
{ label: "OIDC Auth", value: IdentityAuthMethod.OIDC_AUTH },
{
label: "JWT Auth",
value: IdentityAuthMethod.JWT_AUTH
}
];
const schema = z
.object({
authMethod: z.nativeEnum(IdentityAuthMethod)
})
.required();
export type FormData = z.infer<typeof schema>;
export const IdentityAuthMethodModalContent = ({
popUp,
handlePopUpOpen,
handlePopUpToggle,
identity,
initialAuthMethod,
setSelectedAuthMethod
}: Props) => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { mutateAsync: revokeUniversalAuth } = useDeleteIdentityUniversalAuth();
const { mutateAsync: revokeTokenAuth } = useDeleteIdentityTokenAuth();
const { mutateAsync: revokeKubernetesAuth } = useDeleteIdentityKubernetesAuth();
const { mutateAsync: revokeGcpAuth } = useDeleteIdentityGcpAuth();
const { mutateAsync: revokeAwsAuth } = useDeleteIdentityAwsAuth();
const { mutateAsync: revokeAzureAuth } = useDeleteIdentityAzureAuth();
const { mutateAsync: revokeOidcAuth } = useDeleteIdentityOidcAuth();
const { mutateAsync: revokeJwtAuth } = useDeleteIdentityJwtAuth();
const { control, watch } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: async () => {
let authMethod = initialAuthMethod;
if (!authMethod) {
const firstAuthMethodNotConfiguredAuthMethod = identityAuthMethods.find(
({ value }) => !identity?.authMethods?.includes(value)
);
if (firstAuthMethodNotConfiguredAuthMethod) {
authMethod = firstAuthMethodNotConfiguredAuthMethod.value;
}
}
setSelectedAuthMethod(authMethod);
return {
authMethod
};
}
});
const watchedAuthMethod = watch("authMethod");
const identityAuthMethodData = {
identityId: identity.id,
name: identity.name,
authMethod: watch("authMethod"),
configuredAuthMethods: identity.authMethods
} as {
identityId: string;
name: string;
authMethod?: IdentityAuthMethod;
configuredAuthMethods?: IdentityAuthMethod[];
};
const isSelectedAuthAlreadyConfigured =
identityAuthMethodData?.configuredAuthMethods?.includes(watchedAuthMethod);
const methodMap: Record<IdentityAuthMethod, TRevokeMethods | undefined> = {
[IdentityAuthMethod.UNIVERSAL_AUTH]: {
revokeMethod: revokeUniversalAuth,
render: () => (
<IdentityUniversalAuthForm
identityAuthMethodData={identityAuthMethodData}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
)
},
[IdentityAuthMethod.OIDC_AUTH]: {
revokeMethod: revokeOidcAuth,
render: () => (
<IdentityOidcAuthForm
identityAuthMethodData={identityAuthMethodData}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
)
},
[IdentityAuthMethod.TOKEN_AUTH]: {
revokeMethod: revokeTokenAuth,
render: () => (
<IdentityTokenAuthForm
identityAuthMethodData={identityAuthMethodData}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
)
},
[IdentityAuthMethod.AZURE_AUTH]: {
revokeMethod: revokeAzureAuth,
render: () => (
<IdentityAzureAuthForm
identityAuthMethodData={identityAuthMethodData}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
)
},
[IdentityAuthMethod.GCP_AUTH]: {
revokeMethod: revokeGcpAuth,
render: () => (
<IdentityGcpAuthForm
identityAuthMethodData={identityAuthMethodData}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
)
},
[IdentityAuthMethod.KUBERNETES_AUTH]: {
revokeMethod: revokeKubernetesAuth,
render: () => (
<IdentityKubernetesAuthForm
identityAuthMethodData={identityAuthMethodData}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
)
},
[IdentityAuthMethod.AWS_AUTH]: {
revokeMethod: revokeAwsAuth,
render: () => (
<IdentityAwsAuthForm
identityAuthMethodData={identityAuthMethodData}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
)
},
[IdentityAuthMethod.JWT_AUTH]: {
revokeMethod: revokeJwtAuth,
render: () => (
<IdentityJwtAuthForm
identityAuthMethodData={identityAuthMethodData}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
)
}
};
const isAlreadyConfigured = useCallback((method: IdentityAuthMethod) => {
return identityAuthMethodData?.configuredAuthMethods?.includes(method);
}, []);
const selectedMethodItem = methodMap[identityAuthMethodData.authMethod!];
return (
<>
<Controller
control={control}
name="authMethod"
defaultValue={IdentityAuthMethod.UNIVERSAL_AUTH}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Auth Method" errorText={error?.message} isError={Boolean(error)}>
<Select
isDisabled={isSelectedAuthAlreadyConfigured}
defaultValue={field.value}
{...field}
onValueChange={(e) => {
if (!isAlreadyConfigured(e as IdentityAuthMethod)) {
setSelectedAuthMethod(e as IdentityAuthMethod);
onChange(e);
}
}}
className="w-full"
>
{identityAuthMethods.map(({ label, value }) => {
const alreadyConfigured = isAlreadyConfigured(value);
return (
<Tooltip
key={`auth-method-${value}`}
content="Authentication method already configured"
isDisabled={!alreadyConfigured}
>
<SelectItem
isDisabled={alreadyConfigured}
value={String(value || "")}
key={label}
>
{label}{" "}
{alreadyConfigured && !isSelectedAuthAlreadyConfigured && (
<Badge>Configured</Badge>
)}
</SelectItem>
</Tooltip>
);
})}
</Select>
</FormControl>
)}
/>
{selectedMethodItem?.render ? selectedMethodItem.render() : <div />}
<UpgradePlanModal
isOpen={popUp?.upgradePlan?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use IP allowlisting if you switch to Infisical's Pro plan."
/>
<DeleteActionModal
isOpen={popUp?.revokeAuthMethod?.isOpen}
title={`Are you sure want to remove ${
identityAuthMethodData?.authMethod
? identityAuthToNameMap[identityAuthMethodData.authMethod]
: "the auth method"
} on ${identityAuthMethodData?.name ?? ""}?`}
onChange={(isOpen) => handlePopUpToggle("revokeAuthMethod", isOpen)}
deleteKey="confirm"
buttonText="Remove"
onDeleteApproved={async () => {
if (!identityAuthMethodData.authMethod || !orgId || !selectedMethodItem) {
return;
}
try {
await selectedMethodItem.revokeMethod({
identityId: identityAuthMethodData.identityId,
organizationId: orgId
});
createNotification({
text: "Successfully removed auth method",
type: "success"
});
handlePopUpToggle("revokeAuthMethod", false);
handlePopUpToggle("identityAuthMethod", false);
} catch {
createNotification({
text: "Failed to remove auth method",
type: "error"
});
}
}}
/>
</>
);
};

View File

@@ -0,0 +1,377 @@
import { useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, IconButton, Input } from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import {
useAddIdentityAwsAuth,
useGetIdentityAwsAuth,
useUpdateIdentityAwsAuth
} from "@app/hooks/api";
import { IdentityAuthMethod } from "@app/hooks/api/identities";
import { IdentityTrustedIp } from "@app/hooks/api/identities/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
.object({
stsEndpoint: z.string(),
allowedPrincipalArns: z.string(),
allowedAccountIds: z.string(),
accessTokenTTL: z
.string()
.refine(
(value) => Number(value) <= 315360000,
"Access Token TTL cannot be greater than 315360000"
),
accessTokenMaxTTL: z
.string()
.refine(
(value) => Number(value) <= 315360000,
"Access Token Max TTL cannot be greater than 315360000"
),
accessTokenNumUsesLimit: z.string(),
accessTokenTrustedIps: z
.object({
ipAddress: z.string().max(50)
})
.array()
.min(1)
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["identityAuthMethod", "revokeAuthMethod"]>,
state?: boolean
) => void;
identityAuthMethodData: {
identityId: string;
name: string;
configuredAuthMethods?: IdentityAuthMethod[];
authMethod?: IdentityAuthMethod;
};
};
export const IdentityAwsAuthForm = ({
handlePopUpOpen,
handlePopUpToggle,
identityAuthMethodData
}: Props) => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { subscription } = useSubscription();
const { mutateAsync: addMutateAsync } = useAddIdentityAwsAuth();
const { mutateAsync: updateMutateAsync } = useUpdateIdentityAwsAuth();
const isUpdate = identityAuthMethodData?.configuredAuthMethods?.includes(
identityAuthMethodData.authMethod! || ""
);
const { data } = useGetIdentityAwsAuth(identityAuthMethodData?.identityId ?? "", {
enabled: isUpdate
});
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
stsEndpoint: "https://sts.amazonaws.com/",
allowedPrincipalArns: "",
allowedAccountIds: "",
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
}
});
const {
fields: accessTokenTrustedIpsFields,
append: appendAccessTokenTrustedIp,
remove: removeAccessTokenTrustedIp
} = useFieldArray({ control, name: "accessTokenTrustedIps" });
useEffect(() => {
if (data) {
reset({
stsEndpoint: data.stsEndpoint,
allowedPrincipalArns: data.allowedPrincipalArns,
allowedAccountIds: data.allowedAccountIds,
accessTokenTTL: String(data.accessTokenTTL),
accessTokenMaxTTL: String(data.accessTokenMaxTTL),
accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit),
accessTokenTrustedIps: data.accessTokenTrustedIps.map(
({ ipAddress, prefix }: IdentityTrustedIp) => {
return {
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
};
}
)
});
} else {
reset({
stsEndpoint: "https://sts.amazonaws.com/",
allowedPrincipalArns: "",
allowedAccountIds: "",
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
});
}
}, [data]);
const onFormSubmit = async ({
allowedPrincipalArns,
allowedAccountIds,
stsEndpoint,
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
}: FormData) => {
try {
if (!identityAuthMethodData) return;
if (data) {
await updateMutateAsync({
organizationId: orgId,
stsEndpoint,
allowedPrincipalArns,
allowedAccountIds,
identityId: identityAuthMethodData.identityId,
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
});
} else {
await addMutateAsync({
organizationId: orgId,
identityId: identityAuthMethodData.identityId,
stsEndpoint: stsEndpoint || "",
allowedPrincipalArns: allowedPrincipalArns || "",
allowedAccountIds: allowedAccountIds || "",
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
});
}
handlePopUpToggle("identityAuthMethod", false);
createNotification({
text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`,
type: "success"
});
reset();
} catch {
createNotification({
text: `Failed to ${isUpdate ? "update" : "configure"} identity`,
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue="2592000"
name="allowedPrincipalArns"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Allowed Principal ARNs"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="arn:aws:iam::123456789012:role/MyRoleName, arn:aws:iam::123456789012:user/MyUserName..."
type="text"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="allowedAccountIds"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Allowed Account IDs"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="123456789012, ..." />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="https://sts.amazonaws.com/"
name="stsEndpoint"
render={({ field, fieldState: { error } }) => (
<FormControl label="STS Endpoint" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="https://sts.amazonaws.com/" type="text" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="2592000" type="number" min="1" step="1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenMaxTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Max TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="2592000" type="number" min="1" step="1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="0"
name="accessTokenNumUsesLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Max Number of Uses"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="0" type="number" min="0" step="1" />
</FormControl>
)}
/>
{accessTokenTrustedIpsFields.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`accessTokenTrustedIps.${index}.ipAddress`}
defaultValue="0.0.0.0/0"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Access Token Trusted IPs" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input
value={field.value}
onChange={(e) => {
if (subscription?.ipAllowlisting) {
field.onChange(e);
return;
}
handlePopUpOpen("upgradePlan");
}}
placeholder="123.456.789.0"
/>
</FormControl>
);
}}
/>
<IconButton
onClick={() => {
if (subscription?.ipAllowlisting) {
removeAccessTokenTrustedIp(index);
return;
}
handlePopUpOpen("upgradePlan");
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() => {
if (subscription?.ipAllowlisting) {
appendAccessTokenTrustedIp({
ipAddress: "0.0.0.0/0"
});
return;
}
handlePopUpOpen("upgradePlan");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add IP Address
</Button>
</div>
<div className="flex justify-between">
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{!isUpdate ? "Create" : "Edit"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("identityAuthMethod", false)}
>
Cancel
</Button>
</div>
{isUpdate && (
<Button
size="sm"
colorSchema="danger"
isLoading={isSubmitting}
isDisabled={isSubmitting}
onClick={() => handlePopUpToggle("revokeAuthMethod", true)}
>
Remove Auth Method
</Button>
)}
</div>
</form>
);
};

View File

@@ -0,0 +1,373 @@
import { useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, IconButton, Input } from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import {
useAddIdentityAzureAuth,
useGetIdentityAzureAuth,
useUpdateIdentityAzureAuth
} from "@app/hooks/api";
import { IdentityAuthMethod } from "@app/hooks/api/identities";
import { IdentityTrustedIp } from "@app/hooks/api/identities/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
.object({
tenantId: z.string().min(1),
resource: z.string(),
allowedServicePrincipalIds: z.string(),
accessTokenTTL: z.string().refine((val) => Number(val) <= 315360000, {
message: "Access Token TTL cannot be greater than 315360000"
}),
accessTokenMaxTTL: z.string().refine((val) => Number(val) <= 315360000, {
message: "Access Token Max TTL cannot be greater than 315360000"
}),
accessTokenNumUsesLimit: z.string(),
accessTokenTrustedIps: z
.array(
z.object({
ipAddress: z.string().max(50)
})
)
.min(1)
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["identityAuthMethod", "revokeAuthMethod"]>,
state?: boolean
) => void;
identityAuthMethodData: {
identityId: string;
name: string;
configuredAuthMethods?: IdentityAuthMethod[];
authMethod?: IdentityAuthMethod;
};
};
export const IdentityAzureAuthForm = ({
handlePopUpOpen,
handlePopUpToggle,
identityAuthMethodData
}: Props) => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { subscription } = useSubscription();
const { mutateAsync: addMutateAsync } = useAddIdentityAzureAuth();
const { mutateAsync: updateMutateAsync } = useUpdateIdentityAzureAuth();
const isUpdate = identityAuthMethodData?.configuredAuthMethods?.includes(
identityAuthMethodData.authMethod! || ""
);
const { data } = useGetIdentityAzureAuth(identityAuthMethodData?.identityId ?? "", {
enabled: isUpdate
});
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
tenantId: "",
resource: "https://management.azure.com/",
allowedServicePrincipalIds: "",
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
}
});
const {
fields: accessTokenTrustedIpsFields,
append: appendAccessTokenTrustedIp,
remove: removeAccessTokenTrustedIp
} = useFieldArray({ control, name: "accessTokenTrustedIps" });
useEffect(() => {
if (data) {
reset({
tenantId: data.tenantId,
resource: data.resource,
allowedServicePrincipalIds: data.allowedServicePrincipalIds,
accessTokenTTL: String(data.accessTokenTTL),
accessTokenMaxTTL: String(data.accessTokenMaxTTL),
accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit),
accessTokenTrustedIps: data.accessTokenTrustedIps.map(
({ ipAddress, prefix }: IdentityTrustedIp) => {
return {
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
};
}
)
});
} else {
reset({
tenantId: "",
resource: "https://management.azure.com/",
allowedServicePrincipalIds: "",
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
});
}
}, [data]);
const onFormSubmit = async ({
tenantId,
resource,
allowedServicePrincipalIds,
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
}: FormData) => {
try {
if (!identityAuthMethodData) return;
if (data) {
await updateMutateAsync({
organizationId: orgId,
identityId: identityAuthMethodData.identityId,
tenantId,
resource,
allowedServicePrincipalIds,
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
});
} else {
await addMutateAsync({
organizationId: orgId,
identityId: identityAuthMethodData.identityId,
tenantId: tenantId || "",
resource: resource || "",
allowedServicePrincipalIds: allowedServicePrincipalIds || "",
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
});
}
handlePopUpToggle("identityAuthMethod", false);
createNotification({
text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`,
type: "success"
});
reset();
} catch {
createNotification({
text: `Failed to ${isUpdate ? "update" : "configure"} identity`,
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue="2592000"
name="tenantId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Tenant ID"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="00000000-0000-0000-0000-000000000000" type="text" />
</FormControl>
)}
/>
<Controller
control={control}
name="resource"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Resource / Audience"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="https://management.azure.com/" />
</FormControl>
)}
/>
<Controller
control={control}
name="allowedServicePrincipalIds"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Allowed Service Principal IDs"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="00000000-0000-0000-0000-000000000000, ..." />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="2592000" type="number" min="1" step="1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenMaxTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Max TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="2592000" type="number" min="1" step="1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="0"
name="accessTokenNumUsesLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Max Number of Uses"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="0" type="number" min="0" step="1" />
</FormControl>
)}
/>
{accessTokenTrustedIpsFields.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`accessTokenTrustedIps.${index}.ipAddress`}
defaultValue="0.0.0.0/0"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Access Token Trusted IPs" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input
value={field.value}
onChange={(e) => {
if (subscription?.ipAllowlisting) {
field.onChange(e);
return;
}
handlePopUpOpen("upgradePlan");
}}
placeholder="123.456.789.0"
/>
</FormControl>
);
}}
/>
<IconButton
onClick={() => {
if (subscription?.ipAllowlisting) {
removeAccessTokenTrustedIp(index);
return;
}
handlePopUpOpen("upgradePlan");
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() => {
if (subscription?.ipAllowlisting) {
appendAccessTokenTrustedIp({
ipAddress: "0.0.0.0/0"
});
return;
}
handlePopUpOpen("upgradePlan");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add IP Address
</Button>
</div>
<div className="flex justify-between">
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{!isUpdate ? "Create" : "Edit"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("identityAuthMethod", false)}
>
Cancel
</Button>
</div>
{isUpdate && (
<Button
size="sm"
colorSchema="danger"
isLoading={isSubmitting}
isDisabled={isSubmitting}
onClick={() => handlePopUpToggle("revokeAuthMethod", true)}
>
Remove Auth Method
</Button>
)}
</div>
</form>
);
};

View File

@@ -0,0 +1,406 @@
import { useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import {
useAddIdentityGcpAuth,
useGetIdentityGcpAuth,
useUpdateIdentityGcpAuth
} from "@app/hooks/api";
import { IdentityAuthMethod } from "@app/hooks/api/identities";
import { IdentityTrustedIp } from "@app/hooks/api/identities/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
.object({
type: z.enum(["iam", "gce"]),
allowedServiceAccounts: z.string(),
allowedProjects: z.string(),
allowedZones: z.string(),
accessTokenTTL: z.string().refine((val) => Number(val) <= 315360000, {
message: "Access Token TTL cannot be greater than 315360000"
}),
accessTokenMaxTTL: z.string().refine((val) => Number(val) <= 315360000, {
message: "Access Token Max TTL cannot be greater than 315360000"
}),
accessTokenNumUsesLimit: z.string(),
accessTokenTrustedIps: z
.array(
z.object({
ipAddress: z.string().max(50)
})
)
.min(1)
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["identityAuthMethod", "revokeAuthMethod"]>,
state?: boolean
) => void;
identityAuthMethodData: {
identityId: string;
name: string;
configuredAuthMethods?: IdentityAuthMethod[];
authMethod?: IdentityAuthMethod;
};
};
export const IdentityGcpAuthForm = ({
handlePopUpOpen,
handlePopUpToggle,
identityAuthMethodData
}: Props) => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { subscription } = useSubscription();
const { mutateAsync: addMutateAsync } = useAddIdentityGcpAuth();
const { mutateAsync: updateMutateAsync } = useUpdateIdentityGcpAuth();
const isUpdate = identityAuthMethodData?.configuredAuthMethods?.includes(
identityAuthMethodData.authMethod! || ""
);
const { data } = useGetIdentityGcpAuth(identityAuthMethodData?.identityId ?? "", {
enabled: isUpdate
});
const {
control,
handleSubmit,
reset,
formState: { isSubmitting },
watch
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
type: "gce",
allowedServiceAccounts: "",
allowedProjects: "",
allowedZones: "",
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
}
});
const watchedType = watch("type");
const {
fields: accessTokenTrustedIpsFields,
append: appendAccessTokenTrustedIp,
remove: removeAccessTokenTrustedIp
} = useFieldArray({ control, name: "accessTokenTrustedIps" });
useEffect(() => {
if (data) {
reset({
type: data.type || "gce",
allowedServiceAccounts: data.allowedServiceAccounts,
allowedProjects: data.allowedProjects,
allowedZones: data.allowedZones,
accessTokenTTL: String(data.accessTokenTTL),
accessTokenMaxTTL: String(data.accessTokenMaxTTL),
accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit),
accessTokenTrustedIps: data.accessTokenTrustedIps.map(
({ ipAddress, prefix }: IdentityTrustedIp) => {
return {
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
};
}
)
});
} else {
reset({
type: "gce",
allowedServiceAccounts: "",
allowedProjects: "",
allowedZones: "",
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
});
}
}, [data]);
const onFormSubmit = async ({
type,
allowedServiceAccounts,
allowedProjects,
allowedZones,
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
}: FormData) => {
try {
if (!identityAuthMethodData) return;
if (data) {
await updateMutateAsync({
identityId: identityAuthMethodData.identityId,
organizationId: orgId,
type,
allowedServiceAccounts,
allowedProjects,
allowedZones,
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
});
} else {
await addMutateAsync({
identityId: identityAuthMethodData.identityId,
organizationId: orgId,
type,
allowedServiceAccounts: allowedServiceAccounts || "",
allowedProjects: allowedProjects || "",
allowedZones: allowedZones || "",
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
});
}
handlePopUpToggle("identityAuthMethod", false);
createNotification({
text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`,
type: "success"
});
reset();
} catch {
createNotification({
text: `Failed to ${isUpdate ? "update" : "configure"} identity`,
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="type"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Type" isError={Boolean(error)} errorText={error?.message}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
<SelectItem value="gce" key="gce">
GCP ID Token Auth (Recommended)
</SelectItem>
<SelectItem value="iam" key="iam">
GCP IAM Auth
</SelectItem>
</Select>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="2592000"
name="allowedServiceAccounts"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Allowed Service Account Emails"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="test@project.iam.gserviceaccount.com, 12345-compute@developer.gserviceaccount.com"
type="text"
/>
</FormControl>
)}
/>
{watchedType === "gce" && (
<Controller
control={control}
name="allowedProjects"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Allowed Projects"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="my-gcp-project, ..." />
</FormControl>
)}
/>
)}
{watchedType === "gce" && (
<Controller
control={control}
name="allowedZones"
render={({ field, fieldState: { error } }) => (
<FormControl label="Allowed Zones" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="us-west2-a, us-central1-a, ..." />
</FormControl>
)}
/>
)}
<Controller
control={control}
defaultValue="2592000"
name="accessTokenTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="2592000" type="number" min="1" step="1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenMaxTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Max TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="2592000" type="number" min="1" step="1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="0"
name="accessTokenNumUsesLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Max Number of Uses"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="0" type="number" min="0" step="1" />
</FormControl>
)}
/>
{accessTokenTrustedIpsFields.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`accessTokenTrustedIps.${index}.ipAddress`}
defaultValue="0.0.0.0/0"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Access Token Trusted IPs" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input
value={field.value}
onChange={(e) => {
if (subscription?.ipAllowlisting) {
field.onChange(e);
return;
}
handlePopUpOpen("upgradePlan");
}}
placeholder="123.456.789.0"
/>
</FormControl>
);
}}
/>
<IconButton
onClick={() => {
if (subscription?.ipAllowlisting) {
removeAccessTokenTrustedIp(index);
return;
}
handlePopUpOpen("upgradePlan");
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() => {
if (subscription?.ipAllowlisting) {
appendAccessTokenTrustedIp({
ipAddress: "0.0.0.0/0"
});
return;
}
handlePopUpOpen("upgradePlan");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add IP Address
</Button>
</div>
<div className="flex justify-between">
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{!isUpdate ? "Create" : "Edit"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("identityAuthMethod", false)}
>
Cancel
</Button>
</div>
{isUpdate && (
<Button
size="sm"
colorSchema="danger"
isLoading={isSubmitting}
isDisabled={isSubmitting}
onClick={() => handlePopUpToggle("revokeAuthMethod", true)}
>
Remove Auth Method
</Button>
)}
</div>
</form>
);
};

View File

@@ -0,0 +1,688 @@
import { useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons";
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
IconButton,
Input,
Select,
SelectItem,
TextArea,
Tooltip
} from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import { useAddIdentityJwtAuth, useUpdateIdentityJwtAuth } from "@app/hooks/api";
import { IdentityAuthMethod } from "@app/hooks/api/identities";
import { IdentityJwtConfigurationType } from "@app/hooks/api/identities/enums";
import { useGetIdentityJwtAuth } from "@app/hooks/api/identities/queries";
import { IdentityTrustedIp } from "@app/hooks/api/identities/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
const commonSchema = z.object({
accessTokenTrustedIps: z
.array(
z.object({
ipAddress: z.string().max(50)
})
)
.min(1),
accessTokenTTL: z.string().refine((val) => Number(val) <= 315360000, {
message: "Access Token TTL cannot be greater than 315360000"
}),
accessTokenMaxTTL: z.string().refine((val) => Number(val) <= 315360000, {
message: "Access Token Max TTL cannot be greater than 315360000"
}),
accessTokenNumUsesLimit: z.string(),
boundIssuer: z.string().trim().default(""),
boundAudiences: z.string().optional().default(""),
boundClaims: z.array(
z.object({
key: z.string(),
value: z.string()
})
),
boundSubject: z.string().optional().default("")
});
const schema = z.discriminatedUnion("configurationType", [
z
.object({
configurationType: z.literal(IdentityJwtConfigurationType.JWKS),
jwksUrl: z.string().trim().url(),
jwksCaCert: z.string().trim().default(""),
publicKeys: z
.object({
value: z.string()
})
.array()
.optional()
})
.merge(commonSchema),
z
.object({
configurationType: z.literal(IdentityJwtConfigurationType.STATIC),
jwksUrl: z.string().trim().optional(),
jwksCaCert: z.string().trim().optional().default(""),
publicKeys: z
.object({
value: z.string().min(1)
})
.array()
.min(1)
})
.merge(commonSchema)
]);
export type FormData = z.infer<typeof schema>;
type Props = {
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["identityAuthMethod", "revokeAuthMethod"]>,
state?: boolean
) => void;
identityAuthMethodData: {
identityId: string;
name: string;
configuredAuthMethods?: IdentityAuthMethod[];
authMethod?: IdentityAuthMethod;
};
};
export const IdentityJwtAuthForm = ({
handlePopUpOpen,
handlePopUpToggle,
identityAuthMethodData
}: Props) => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { subscription } = useSubscription();
const { mutateAsync: addMutateAsync } = useAddIdentityJwtAuth();
const { mutateAsync: updateMutateAsync } = useUpdateIdentityJwtAuth();
const isUpdate = identityAuthMethodData?.configuredAuthMethods?.includes(
identityAuthMethodData.authMethod! || ""
);
const { data } = useGetIdentityJwtAuth(identityAuthMethodData?.identityId ?? "", {
enabled: isUpdate
});
const {
watch,
control,
handleSubmit,
reset,
setValue,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
configurationType: IdentityJwtConfigurationType.JWKS
}
});
const selectedConfigurationType = watch("configurationType") as IdentityJwtConfigurationType;
const {
fields: publicKeyFields,
append: appendPublicKeyFields,
remove: removePublicKeyFields
} = useFieldArray({
control,
name: "publicKeys"
});
const {
fields: boundClaimsFields,
append: appendBoundClaimField,
remove: removeBoundClaimField
} = useFieldArray({
control,
name: "boundClaims"
});
const {
fields: accessTokenTrustedIpsFields,
append: appendAccessTokenTrustedIp,
remove: removeAccessTokenTrustedIp
} = useFieldArray({ control, name: "accessTokenTrustedIps" });
useEffect(() => {
if (data) {
reset({
configurationType: data.configurationType,
jwksUrl: data.jwksUrl,
jwksCaCert: data.jwksCaCert,
publicKeys: data.publicKeys.map((pk) => ({
value: pk
})),
boundIssuer: data.boundIssuer,
boundAudiences: data.boundAudiences,
boundClaims: Object.entries(data.boundClaims).map(([key, value]) => ({
key,
value
})),
boundSubject: data.boundSubject,
accessTokenTTL: String(data.accessTokenTTL),
accessTokenMaxTTL: String(data.accessTokenMaxTTL),
accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit),
accessTokenTrustedIps: data.accessTokenTrustedIps.map(
({ ipAddress, prefix }: IdentityTrustedIp) => {
return {
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
};
}
)
});
} else {
reset({
configurationType: IdentityJwtConfigurationType.JWKS,
jwksUrl: "",
jwksCaCert: "",
boundIssuer: "",
boundAudiences: "",
boundClaims: [],
boundSubject: "",
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
});
}
}, [data]);
const onFormSubmit = async ({
accessTokenTrustedIps,
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
configurationType,
jwksUrl,
jwksCaCert,
publicKeys,
boundIssuer,
boundAudiences,
boundClaims,
boundSubject
}: FormData) => {
try {
if (!identityAuthMethodData) {
return;
}
if (data) {
await updateMutateAsync({
identityId: identityAuthMethodData.identityId,
organizationId: orgId,
configurationType,
jwksUrl,
jwksCaCert,
publicKeys: publicKeys?.map((field) => field.value).filter(Boolean),
boundIssuer,
boundAudiences,
boundClaims: Object.fromEntries(boundClaims.map((entry) => [entry.key, entry.value])),
boundSubject,
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
});
} else {
await addMutateAsync({
identityId: identityAuthMethodData.identityId,
configurationType,
jwksUrl,
jwksCaCert,
publicKeys: publicKeys?.map((field) => field.value).filter(Boolean),
boundIssuer,
boundAudiences,
boundClaims: Object.fromEntries(boundClaims.map((entry) => [entry.key, entry.value])),
boundSubject,
organizationId: orgId,
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
});
}
handlePopUpToggle("identityAuthMethod", false);
createNotification({
text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`,
type: "success"
});
reset();
} catch {
createNotification({
text: `Failed to ${isUpdate ? "update" : "configure"} identity`,
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="configurationType"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Configuration Type"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => {
if (e === IdentityJwtConfigurationType.JWKS) {
setValue("publicKeys", []);
} else {
setValue("publicKeys", [
{
value: ""
}
]);
setValue("jwksUrl", "");
setValue("jwksCaCert", "");
}
onChange(e);
}}
className="w-full"
>
<SelectItem value={IdentityJwtConfigurationType.JWKS} key="jwks">
JWKS
</SelectItem>
<SelectItem value={IdentityJwtConfigurationType.STATIC} key="static">
Static
</SelectItem>
</Select>
</FormControl>
)}
/>
{selectedConfigurationType === IdentityJwtConfigurationType.JWKS && (
<>
<Controller
control={control}
name="jwksUrl"
render={({ field, fieldState: { error } }) => (
<FormControl
isRequired
label="JWKS URL"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} type="text" />
</FormControl>
)}
/>
<Controller
control={control}
name="jwksCaCert"
render={({ field, fieldState: { error } }) => (
<FormControl
label="JWKS CA Certificate"
errorText={error?.message}
isError={Boolean(error)}
>
<TextArea {...field} placeholder="-----BEGIN CERTIFICATE----- ..." />
</FormControl>
)}
/>
</>
)}
{selectedConfigurationType === IdentityJwtConfigurationType.STATIC && (
<>
{publicKeyFields.map(({ id }, index) => (
<div key={id} className="flex gap-2">
<Controller
control={control}
name={`publicKeys.${index}.value`}
render={({ field, fieldState: { error } }) => (
<FormControl
className="flex-grow"
label={`Public Key ${index + 1}`}
errorText={error?.message}
isError={Boolean(error)}
icon={
<Tooltip
className="text-center"
content={<span>This field only accepts PEM-formatted public keys</span>}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
</Tooltip>
}
>
<TextArea {...field} placeholder="-----BEGIN PUBLIC KEY----- ..." />
</FormControl>
)}
/>
<IconButton
onClick={() => {
if (publicKeyFields.length === 1) {
createNotification({
type: "error",
text: "A public key is required for static configurations"
});
return;
}
removePublicKeyFields(index);
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() =>
appendPublicKeyFields({
value: ""
})
}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add Public Key
</Button>
</div>
</>
)}
<Controller
control={control}
name="boundIssuer"
render={({ field, fieldState: { error } }) => (
<FormControl label="Issuer" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} type="text" />
</FormControl>
)}
/>
<Controller
control={control}
name="boundSubject"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Subject"
isError={Boolean(error)}
errorText={error?.message}
icon={
<Tooltip
className="text-center"
content={<span>This field supports glob patterns</span>}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
</Tooltip>
}
>
<Input {...field} type="text" />
</FormControl>
)}
/>
<Controller
control={control}
name="boundAudiences"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Audiences"
isError={Boolean(error)}
errorText={error?.message}
icon={
<Tooltip
className="text-center"
content={<span>This field supports glob patterns</span>}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
</Tooltip>
}
>
<Input {...field} type="text" placeholder="service1, service2" />
</FormControl>
)}
/>
{boundClaimsFields.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`boundClaims.${index}.key`}
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Claims" : undefined}
icon={
index === 0 ? (
<Tooltip
className="text-center"
content={<span>This field supports glob patterns</span>}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
</Tooltip>
) : undefined
}
isError={Boolean(error)}
errorText={error?.message}
>
<Input
value={field.value}
onChange={(e) => field.onChange(e)}
placeholder="property"
/>
</FormControl>
);
}}
/>
<Controller
control={control}
name={`boundClaims.${index}.value`}
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className="mb-0 flex-grow"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
value={field.value}
onChange={(e) => field.onChange(e)}
placeholder="value1, value2"
/>
</FormControl>
);
}}
/>
<IconButton
onClick={() => removeBoundClaimField(index)}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() =>
appendBoundClaimField({
key: "",
value: ""
})
}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add Claims
</Button>
</div>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="2592000" type="number" min="1" step="1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenMaxTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Max TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="2592000" type="number" min="1" step="1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="0"
name="accessTokenNumUsesLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Max Number of Uses"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="0" type="number" min="0" step="1" />
</FormControl>
)}
/>
{accessTokenTrustedIpsFields.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`accessTokenTrustedIps.${index}.ipAddress`}
defaultValue="0.0.0.0/0"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Access Token Trusted IPs" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input
value={field.value}
onChange={(e) => {
if (subscription?.ipAllowlisting) {
field.onChange(e);
return;
}
handlePopUpOpen("upgradePlan");
}}
placeholder="123.456.789.0"
/>
</FormControl>
);
}}
/>
<IconButton
onClick={() => {
if (subscription?.ipAllowlisting) {
removeAccessTokenTrustedIp(index);
return;
}
handlePopUpOpen("upgradePlan");
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() => {
if (subscription?.ipAllowlisting) {
appendAccessTokenTrustedIp({
ipAddress: "0.0.0.0/0"
});
return;
}
handlePopUpOpen("upgradePlan");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add IP Address
</Button>
</div>
<div className="flex justify-between">
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{isUpdate ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("identityAuthMethod", false)}
>
Cancel
</Button>
</div>
{isUpdate && (
<Button
size="sm"
colorSchema="danger"
isLoading={isSubmitting}
isDisabled={isSubmitting}
onClick={() => handlePopUpToggle("revokeAuthMethod", true)}
>
Remove Auth Method
</Button>
)}
</div>
</form>
);
};

View File

@@ -0,0 +1,446 @@
import { useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, IconButton, Input, TextArea } from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import {
useAddIdentityKubernetesAuth,
useGetIdentityKubernetesAuth,
useUpdateIdentityKubernetesAuth
} from "@app/hooks/api";
import { IdentityAuthMethod } from "@app/hooks/api/identities";
import { IdentityTrustedIp } from "@app/hooks/api/identities/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
.object({
kubernetesHost: z.string().min(1),
tokenReviewerJwt: z.string().min(1),
allowedNames: z.string(),
allowedNamespaces: z.string(),
allowedAudience: z.string(),
caCert: z.string(),
accessTokenTTL: z.string().refine((val) => Number(val) <= 315360000, {
message: "Access Token TTL cannot be greater than 315360000"
}),
accessTokenMaxTTL: z.string().refine((val) => Number(val) <= 315360000, {
message: "Access Token Max TTL cannot be greater than 315360000"
}),
accessTokenNumUsesLimit: z.string(),
accessTokenTrustedIps: z
.array(
z.object({
ipAddress: z.string().max(50)
})
)
.min(1)
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["identityAuthMethod", "revokeAuthMethod"]>,
state?: boolean
) => void;
identityAuthMethodData: {
identityId: string;
name: string;
configuredAuthMethods?: IdentityAuthMethod[];
authMethod?: IdentityAuthMethod;
};
};
export const IdentityKubernetesAuthForm = ({
handlePopUpOpen,
handlePopUpToggle,
identityAuthMethodData
}: Props) => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { subscription } = useSubscription();
const { mutateAsync: addMutateAsync } = useAddIdentityKubernetesAuth();
const { mutateAsync: updateMutateAsync } = useUpdateIdentityKubernetesAuth();
const isUpdate = identityAuthMethodData?.configuredAuthMethods?.includes(
identityAuthMethodData.authMethod! || ""
);
const { data } = useGetIdentityKubernetesAuth(identityAuthMethodData?.identityId ?? "", {
enabled: isUpdate
});
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
kubernetesHost: "",
tokenReviewerJwt: "",
allowedNames: "",
allowedNamespaces: "",
allowedAudience: "",
caCert: "",
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
}
});
const {
fields: accessTokenTrustedIpsFields,
append: appendAccessTokenTrustedIp,
remove: removeAccessTokenTrustedIp
} = useFieldArray({ control, name: "accessTokenTrustedIps" });
useEffect(() => {
if (data) {
reset({
kubernetesHost: data.kubernetesHost,
tokenReviewerJwt: data.tokenReviewerJwt,
allowedNames: data.allowedNames,
allowedNamespaces: data.allowedNamespaces,
allowedAudience: data.allowedAudience,
caCert: data.caCert,
accessTokenTTL: String(data.accessTokenTTL),
accessTokenMaxTTL: String(data.accessTokenMaxTTL),
accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit),
accessTokenTrustedIps: data.accessTokenTrustedIps.map(
({ ipAddress, prefix }: IdentityTrustedIp) => {
return {
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
};
}
)
});
} else {
reset({
kubernetesHost: "",
tokenReviewerJwt: "",
allowedNames: "",
allowedNamespaces: "",
allowedAudience: "",
caCert: "",
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
});
}
}, [data]);
const onFormSubmit = async ({
kubernetesHost,
tokenReviewerJwt,
allowedNames,
allowedNamespaces,
allowedAudience,
caCert,
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
}: FormData) => {
try {
if (!identityAuthMethodData) return;
if (data) {
await updateMutateAsync({
organizationId: orgId,
kubernetesHost,
tokenReviewerJwt,
allowedNames,
allowedNamespaces,
allowedAudience,
caCert,
identityId: identityAuthMethodData.identityId,
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
});
} else {
await addMutateAsync({
organizationId: orgId,
identityId: identityAuthMethodData.identityId,
kubernetesHost: kubernetesHost || "",
tokenReviewerJwt,
allowedNames: allowedNames || "",
allowedNamespaces: allowedNamespaces || "",
allowedAudience: allowedAudience || "",
caCert: caCert || "",
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
});
}
handlePopUpToggle("identityAuthMethod", false);
createNotification({
text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`,
type: "success"
});
reset();
} catch {
createNotification({
text: `Failed to ${isUpdate ? "update" : "configure"} identity`,
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue="2592000"
name="kubernetesHost"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Kubernetes Host / Base Kubernetes API URL "
isError={Boolean(error)}
errorText={error?.message}
tooltipText="The host string, host:port pair, or URL to the base of the Kubernetes API server. This can usually be obtained by running 'kubectl cluster-info'"
isRequired
>
<Input {...field} placeholder="https://my-example-k8s-api-host.com" type="text" />
</FormControl>
)}
/>
<Controller
control={control}
name="tokenReviewerJwt"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Token Reviewer JWT"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="A long-lived service account JWT token for Infisical to access the TokenReview API to validate other service account JWT tokens submitted by applications/pods."
isRequired
>
<Input {...field} placeholder="" type="password" />
</FormControl>
)}
/>
<Controller
control={control}
name="allowedNames"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Allowed Service Account Names"
isError={Boolean(error)}
tooltipText="An optional comma-separated list of trusted service account names that are allowed to authenticate with Infisical. Leave empty to allow any service account."
errorText={error?.message}
>
<Input {...field} placeholder="service-account-1-name, service-account-1-name" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="allowedNamespaces"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Allowed Namespaces"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="A comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical."
>
<Input {...field} placeholder="namespaceA, namespaceB" type="text" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="allowedAudience"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Allowed Audience"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="An optional audience claim that the service account JWT token must have to authenticate with Infisical. Leave empty to allow any audience claim."
>
<Input {...field} placeholder="" type="text" />
</FormControl>
)}
/>
<Controller
control={control}
name="caCert"
render={({ field, fieldState: { error } }) => (
<FormControl
label="CA Certificate"
errorText={error?.message}
isError={Boolean(error)}
tooltipText="An optional PEM-encoded CA cert for the Kubernetes API server. This is used by the TLS client for secure communication with the Kubernetes API server."
>
<TextArea {...field} placeholder="-----BEGIN CERTIFICATE----- ..." />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token TTL (seconds)"
tooltipText="The lifetime for an acccess token in seconds. This value will be referenced at renewal time."
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="2592000" type="number" min="1" step="1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenMaxTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Max TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="The maximum lifetime for an access token in seconds. This value will be referenced at renewal time."
>
<Input {...field} placeholder="2592000" type="number" min="1" step="1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="0"
name="accessTokenNumUsesLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Max Number of Uses"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="The maximum number of times that an access token can be used; a value of 0 implies infinite number of uses."
>
<Input {...field} placeholder="0" type="number" min="0" step="1" />
</FormControl>
)}
/>
{accessTokenTrustedIpsFields.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`accessTokenTrustedIps.${index}.ipAddress`}
defaultValue="0.0.0.0/0"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Access Token Trusted IPs" : undefined}
isError={Boolean(error)}
errorText={error?.message}
tooltipText="The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the 0.0.0.0/0, allowing usage from any network address."
>
<Input
value={field.value}
onChange={(e) => {
if (subscription?.ipAllowlisting) {
field.onChange(e);
return;
}
handlePopUpOpen("upgradePlan");
}}
placeholder="123.456.789.0"
/>
</FormControl>
);
}}
/>
<IconButton
onClick={() => {
if (subscription?.ipAllowlisting) {
removeAccessTokenTrustedIp(index);
return;
}
handlePopUpOpen("upgradePlan");
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() => {
if (subscription?.ipAllowlisting) {
appendAccessTokenTrustedIp({
ipAddress: "0.0.0.0/0"
});
return;
}
handlePopUpOpen("upgradePlan");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add IP Address
</Button>
</div>
<div className="flex justify-between">
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{isUpdate ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("identityAuthMethod", false)}
>
Cancel
</Button>
</div>
{isUpdate && (
<Button
size="sm"
colorSchema="danger"
isLoading={isSubmitting}
isDisabled={isSubmitting}
onClick={() => handlePopUpToggle("revokeAuthMethod", true)}
>
Remove Auth Method
</Button>
)}
</div>
</form>
);
};

View File

@@ -0,0 +1,301 @@
import { useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
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,
FormLabel,
IconButton,
Input,
Modal,
ModalContent
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { findOrgMembershipRole } from "@app/helpers/roles";
import { useCreateIdentity, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api";
import { useAddIdentityUniversalAuth } from "@app/hooks/api/identities";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
.object({
name: z.string().min(1, "Required"),
role: z.object({ slug: z.string(), name: z.string() }),
metadata: z
.object({
key: z.string().trim().min(1),
value: z.string().trim().min(1)
})
.array()
.default([])
.optional()
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["identity"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["identity"]>, state?: boolean) => void;
};
export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
const navigate = useNavigate();
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { data: roles } = useGetOrgRoles(orgId);
const { mutateAsync: createMutateAsync } = useCreateIdentity();
const { mutateAsync: updateMutateAsync } = useUpdateIdentity();
const { mutateAsync: addMutateAsync } = useAddIdentityUniversalAuth();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: ""
}
});
const metadataFormFields = useFieldArray({
control,
name: "metadata"
});
useEffect(() => {
const identity = popUp?.identity?.data as {
identityId: string;
name: string;
role: string;
metadata?: { key: string; value: string }[];
customRole: {
name: string;
slug: string;
};
};
if (!roles?.length) return;
if (identity) {
reset({
name: identity.name,
role: identity.customRole ?? findOrgMembershipRole(roles, identity.role),
metadata: identity.metadata
});
} else {
reset({
name: "",
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
});
}
}, [popUp?.identity?.data, roles]);
const onFormSubmit = async ({ name, role, metadata }: FormData) => {
try {
const identity = popUp?.identity?.data as {
identityId: string;
name: string;
role: string;
};
if (identity) {
// update
await updateMutateAsync({
identityId: identity.identityId,
name,
role: role.slug || undefined,
organizationId: orgId,
metadata
});
handlePopUpToggle("identity", false);
} else {
// create
const { id: createdId } = await createMutateAsync({
name,
role: role.slug || undefined,
organizationId: orgId,
metadata
});
await addMutateAsync({
organizationId: orgId,
identityId: createdId,
clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
accessTokenTTL: 2592000,
accessTokenMaxTTL: 2592000,
accessTokenNumUsesLimit: 0
});
handlePopUpToggle("identity", false);
navigate({
to: "/organization/$organizationId/identities/$identityId",
params: {
organizationId: currentOrg.id,
identityId: createdId
}
});
}
createNotification({
text: `Successfully ${popUp?.identity?.data ? "updated" : "created"} identity`,
type: "success"
});
reset();
} catch (err) {
console.error(err);
const error = err as any;
const text =
error?.response?.data?.message ??
`Failed to ${popUp?.identity?.data ? "update" : "create"} identity`;
createNotification({
text,
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.identity?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("identity", isOpen);
reset();
}}
>
<ModalContent
bodyClassName="overflow-visible"
title={`${popUp?.identity?.data ? "Update" : "Create"} Identity`}
>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="Machine 1" />
</FormControl>
)}
/>
<Controller
control={control}
name="role"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label={`${popUp?.identity?.data ? "Update" : ""} Role`}
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<FilterableSelect
placeholder="Select role..."
options={roles}
onChange={onChange}
value={value}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<div>
<FormLabel label="Metadata" />
</div>
<div className="mb-3 flex flex-col space-y-2">
{metadataFormFields.fields.map(({ id: metadataFieldId }, i) => (
<div key={metadataFieldId} className="flex items-end space-x-2">
<div className="flex-grow">
{i === 0 && <span className="text-xs text-mineshaft-400">Key</span>}
<Controller
control={control}
name={`metadata.${i}.key`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="flex-grow">
{i === 0 && (
<FormLabel label="Value" className="text-xs text-mineshaft-400" isOptional />
)}
<Controller
control={control}
name={`metadata.${i}.value`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<IconButton
ariaLabel="delete key"
className="bottom-0.5 h-9"
variant="outline_bg"
onClick={() => metadataFormFields.remove(i)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<div className="mt-2 flex justify-end">
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => metadataFormFields.append({ key: "", value: "" })}
>
Add Key
</Button>
</div>
</div>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.identity?.data ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("identity", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,527 @@
import { useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons";
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, IconButton, Input, TextArea, Tooltip } from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import { useAddIdentityOidcAuth, useUpdateIdentityOidcAuth } from "@app/hooks/api";
import { IdentityAuthMethod } from "@app/hooks/api/identities";
import { useGetIdentityOidcAuth } from "@app/hooks/api/identities/queries";
import { IdentityTrustedIp } from "@app/hooks/api/identities/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
accessTokenTrustedIps: z
.array(
z.object({
ipAddress: z.string().max(50)
})
)
.min(1),
accessTokenTTL: z.string().refine((val) => Number(val) <= 315360000, {
message: "Access Token TTL cannot be greater than 315360000"
}),
accessTokenMaxTTL: z.string().refine((val) => Number(val) <= 315360000, {
message: "Access Token Max TTL cannot be greater than 315360000"
}),
accessTokenNumUsesLimit: z.string(),
oidcDiscoveryUrl: z.string().url().min(1),
caCert: z.string().trim().default(""),
boundIssuer: z.string().min(1),
boundAudiences: z.string().optional().default(""),
boundClaims: z.array(
z.object({
key: z.string(),
value: z.string()
})
),
boundSubject: z.string().optional().default("")
});
export type FormData = z.infer<typeof schema>;
type Props = {
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["identityAuthMethod", "revokeAuthMethod"]>,
state?: boolean
) => void;
identityAuthMethodData: {
identityId: string;
name: string;
configuredAuthMethods?: IdentityAuthMethod[];
authMethod?: IdentityAuthMethod;
};
};
export const IdentityOidcAuthForm = ({
handlePopUpOpen,
handlePopUpToggle,
identityAuthMethodData
}: Props) => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { subscription } = useSubscription();
const { mutateAsync: addMutateAsync } = useAddIdentityOidcAuth();
const { mutateAsync: updateMutateAsync } = useUpdateIdentityOidcAuth();
const isUpdate = identityAuthMethodData?.configuredAuthMethods?.includes(
identityAuthMethodData.authMethod! || ""
);
const { data } = useGetIdentityOidcAuth(identityAuthMethodData?.identityId ?? "", {
enabled: isUpdate
});
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
}
});
const {
fields: boundClaimsFields,
append: appendBoundClaimField,
remove: removeBoundClaimField
} = useFieldArray({
control,
name: "boundClaims"
});
const {
fields: accessTokenTrustedIpsFields,
append: appendAccessTokenTrustedIp,
remove: removeAccessTokenTrustedIp
} = useFieldArray({ control, name: "accessTokenTrustedIps" });
useEffect(() => {
if (data) {
reset({
oidcDiscoveryUrl: data.oidcDiscoveryUrl,
caCert: data.caCert,
boundIssuer: data.boundIssuer,
boundAudiences: data.boundAudiences,
boundClaims: Object.entries(data.boundClaims).map(([key, value]) => ({
key,
value
})),
boundSubject: data.boundSubject,
accessTokenTTL: String(data.accessTokenTTL),
accessTokenMaxTTL: String(data.accessTokenMaxTTL),
accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit),
accessTokenTrustedIps: data.accessTokenTrustedIps.map(
({ ipAddress, prefix }: IdentityTrustedIp) => {
return {
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
};
}
)
});
} else {
reset({
oidcDiscoveryUrl: "",
caCert: "",
boundIssuer: "",
boundAudiences: "",
boundClaims: [],
boundSubject: "",
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
});
}
}, [data]);
const onFormSubmit = async ({
accessTokenTrustedIps,
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
oidcDiscoveryUrl,
caCert,
boundIssuer,
boundAudiences,
boundClaims,
boundSubject
}: FormData) => {
try {
if (!identityAuthMethodData) {
return;
}
if (data) {
await updateMutateAsync({
identityId: identityAuthMethodData.identityId,
organizationId: orgId,
oidcDiscoveryUrl,
caCert,
boundIssuer,
boundAudiences,
boundClaims: Object.fromEntries(boundClaims.map((entry) => [entry.key, entry.value])),
boundSubject,
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
});
} else {
await addMutateAsync({
identityId: identityAuthMethodData.identityId,
oidcDiscoveryUrl,
caCert,
boundIssuer,
boundAudiences,
boundClaims: Object.fromEntries(boundClaims.map((entry) => [entry.key, entry.value])),
boundSubject,
organizationId: orgId,
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
});
}
handlePopUpToggle("identityAuthMethod", false);
createNotification({
text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`,
type: "success"
});
reset();
} catch {
createNotification({
text: `Failed to ${isUpdate ? "update" : "configure"} identity`,
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="oidcDiscoveryUrl"
render={({ field, fieldState: { error } }) => (
<FormControl
isRequired
label="OIDC Discovery URL"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="https://token.actions.githubusercontent.com"
type="text"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="boundIssuer"
render={({ field, fieldState: { error } }) => (
<FormControl
isRequired
label="Issuer"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
type="text"
placeholder="https://token.actions.githubusercontent.com"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="caCert"
render={({ field, fieldState: { error } }) => (
<FormControl label="CA Certificate" errorText={error?.message} isError={Boolean(error)}>
<TextArea {...field} placeholder="-----BEGIN CERTIFICATE----- ..." />
</FormControl>
)}
/>
<Controller
control={control}
name="boundSubject"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Subject"
isError={Boolean(error)}
errorText={error?.message}
icon={
<Tooltip
className="text-center"
content={<span>This field supports glob patterns</span>}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
</Tooltip>
}
>
<Input {...field} type="text" />
</FormControl>
)}
/>
<Controller
control={control}
name="boundAudiences"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Audiences"
isError={Boolean(error)}
errorText={error?.message}
icon={
<Tooltip
className="text-center"
content={<span>This field supports glob patterns</span>}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
</Tooltip>
}
>
<Input {...field} type="text" placeholder="service1, service2" />
</FormControl>
)}
/>
{boundClaimsFields.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`boundClaims.${index}.key`}
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Claims" : undefined}
icon={
index === 0 ? (
<Tooltip
className="text-center"
content={<span>This field supports glob patterns</span>}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
</Tooltip>
) : undefined
}
isError={Boolean(error)}
errorText={error?.message}
>
<Input
value={field.value}
onChange={(e) => field.onChange(e)}
placeholder="property"
/>
</FormControl>
);
}}
/>
<Controller
control={control}
name={`boundClaims.${index}.value`}
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className="mb-0 flex-grow"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
value={field.value}
onChange={(e) => field.onChange(e)}
placeholder="value1, value2"
/>
</FormControl>
);
}}
/>
<IconButton
onClick={() => removeBoundClaimField(index)}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() =>
appendBoundClaimField({
key: "",
value: ""
})
}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add Claims
</Button>
</div>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="2592000" type="number" min="1" step="1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenMaxTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Max TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="2592000" type="number" min="1" step="1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="0"
name="accessTokenNumUsesLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Max Number of Uses"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="0" type="number" min="0" step="1" />
</FormControl>
)}
/>
{accessTokenTrustedIpsFields.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`accessTokenTrustedIps.${index}.ipAddress`}
defaultValue="0.0.0.0/0"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Access Token Trusted IPs" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input
value={field.value}
onChange={(e) => {
if (subscription?.ipAllowlisting) {
field.onChange(e);
return;
}
handlePopUpOpen("upgradePlan");
}}
placeholder="123.456.789.0"
/>
</FormControl>
);
}}
/>
<IconButton
onClick={() => {
if (subscription?.ipAllowlisting) {
removeAccessTokenTrustedIp(index);
return;
}
handlePopUpOpen("upgradePlan");
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() => {
if (subscription?.ipAllowlisting) {
appendAccessTokenTrustedIp({
ipAddress: "0.0.0.0/0"
});
return;
}
handlePopUpOpen("upgradePlan");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add IP Address
</Button>
</div>
<div className="flex justify-between">
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{isUpdate ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("identityAuthMethod", false)}
>
Cancel
</Button>
</div>
{isUpdate && (
<Button
size="sm"
colorSchema="danger"
isLoading={isSubmitting}
isDisabled={isSubmitting}
onClick={() => handlePopUpToggle("revokeAuthMethod", true)}
>
Remove Auth Method
</Button>
)}
</div>
</form>
);
};

View File

@@ -0,0 +1,146 @@
import { faArrowUpRightFromSquare, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} from "@app/context";
import { withPermission } from "@app/hoc";
import { useDeleteIdentity } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
// import { IdentityAuthMethodModal } from "./IdentityAuthMethodModal";
import { IdentityModal } from "./IdentityModal";
import { IdentityTable } from "./IdentityTable";
import { IdentityTokenAuthTokenModal } from "./IdentityTokenAuthTokenModal";
// import { IdentityUniversalAuthClientSecretModal } from "./IdentityUniversalAuthClientSecretModal";
export const IdentitySection = withPermission(
() => {
const { subscription } = useSubscription();
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { mutateAsync: deleteMutateAsync } = useDeleteIdentity();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"identity",
"identityAuthMethod",
"deleteIdentity",
"universalAuthClientSecret",
"deleteUniversalAuthClientSecret",
"upgradePlan",
"tokenAuthToken"
] as const);
const isMoreIdentitiesAllowed = subscription?.identityLimit
? subscription.identitiesUsed < subscription.identityLimit
: true;
const isEnterprise = subscription?.slug === "enterprise";
const onDeleteIdentitySubmit = async (identityId: string) => {
try {
await deleteMutateAsync({
identityId,
organizationId: orgId
});
createNotification({
text: "Successfully deleted identity",
type: "success"
});
handlePopUpClose("deleteIdentity");
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to delete identity";
createNotification({
text,
type: "error"
});
}
};
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
<div className="flex w-full justify-end pr-4">
<a
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/identities/overview"
className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white"
>
Documentation{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</a>
</div>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Identity}>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
if (!isMoreIdentitiesAllowed && !isEnterprise) {
handlePopUpOpen("upgradePlan", {
description: "You can add more identities if you upgrade your Infisical plan."
});
return;
}
handlePopUpOpen("identity");
}}
isDisabled={!isAllowed}
>
Create Identity
</Button>
)}
</OrgPermissionCan>
</div>
<IdentityTable handlePopUpOpen={handlePopUpOpen} />
<IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
{/* <IdentityAuthMethodModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/> */}
{/* <IdentityUniversalAuthClientSecretModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/> */}
<IdentityTokenAuthTokenModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen}
title={`Are you sure want to delete ${
(popUp?.deleteIdentity?.data as { name: string })?.name || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onDeleteIdentitySubmit(
(popUp?.deleteIdentity?.data as { identityId: string })?.identityId
)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Identity }
);

View File

@@ -0,0 +1,319 @@
import {
faArrowDown,
faArrowUp,
faEllipsis,
faMagnifyingGlass,
faServer
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Select,
SelectItem,
Spinner,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetIdentityMembershipOrgs, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { OrgIdentityOrderBy } from "@app/hooks/api/organization/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { useNavigate } from "@tanstack/react-router";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteIdentity"]>,
data?: {
identityId: string;
name: string;
}
) => void;
};
export const IdentityTable = ({ handlePopUpOpen }: Props) => {
const navigate = useNavigate();
const { currentOrg } = useOrganization();
const {
offset,
limit,
orderBy,
setOrderBy,
orderDirection,
setOrderDirection,
search,
debouncedSearch,
setPage,
setSearch,
perPage,
page,
setPerPage
} = usePagination<OrgIdentityOrderBy>(OrgIdentityOrderBy.Name);
const organizationId = currentOrg?.id || "";
const { mutateAsync: updateMutateAsync } = useUpdateIdentity();
const { data, isLoading, isFetching } = useGetIdentityMembershipOrgs(
{
organizationId,
offset,
limit,
orderDirection,
orderBy,
search: debouncedSearch
},
{ keepPreviousData: true }
);
const { totalCount = 0 } = data ?? {};
useResetPageHelper({
totalCount,
offset,
setPage
});
const { data: roles } = useGetOrgRoles(organizationId);
const handleSort = (column: OrgIdentityOrderBy) => {
if (column === orderBy) {
setOrderDirection((prev) =>
prev === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC
);
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const handleChangeRole = async ({ identityId, role }: { identityId: string; role: string }) => {
try {
await updateMutateAsync({
identityId,
role,
organizationId
});
createNotification({
text: "Successfully updated identity role",
type: "success"
});
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to update identity role";
createNotification({
text,
type: "error"
});
}
};
return (
<div>
<Input
containerClassName="mb-4"
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search identities by name..."
/>
<TableContainer>
<Table>
<THead>
<Tr className="h-14">
<Th className="w-1/2">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={`ml-2 ${orderBy === OrgIdentityOrderBy.Name ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(OrgIdentityOrderBy.Name)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC &&
orderBy === OrgIdentityOrderBy.Name
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th>Role</Th>
{/* <Th>
<div className="flex items-center">
Role
<IconButton
variant="plain"
className={`ml-2 ${orderBy === OrgIdentityOrderBy.Role ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(OrgIdentityOrderBy.Role)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC &&
orderBy === OrgIdentityOrderBy.Role
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th> */}
<Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={3} innerKey="org-identities" />}
{!isLoading &&
data?.identityMemberships.map(({ identity: { id, name }, role, customRole }) => {
return (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`identity-${id}`}
onClick={() =>
navigate({
to: "/organization/$organizationId/identities/$identityId",
params: {
organizationId,
identityId: id
}
})
}
>
<Td>{name}</Td>
<Td>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => {
return (
<Select
value={role === "custom" ? (customRole?.slug as string) : role}
isDisabled={!isAllowed}
className="w-48 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
handleChangeRole({
identityId: id,
role: selectedRole
})
}
>
{(roles || []).map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
);
}}
</OrgPermissionCan>
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="flex justify-center hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
navigate.push(`/org/${organizationId}/identities/${id}`);
}}
disabled={!isAllowed}
>
Edit Identity
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
disabled={!isAllowed}
>
Delete Identity
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && data && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isLoading && data && data?.identityMemberships.length === 0 && (
<EmptyState
title={
debouncedSearch.trim().length > 0
? "No identities match search filter"
: "No identities have been created in this organization"
}
icon={faServer}
/>
)}
</TableContainer>
</div>
);
};

View File

@@ -0,0 +1,285 @@
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, IconButton, Input } from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import {
useAddIdentityTokenAuth,
useGetIdentityTokenAuth,
useUpdateIdentityTokenAuth
} from "@app/hooks/api";
import { IdentityAuthMethod } from "@app/hooks/api/identities";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
.object({
accessTokenTTL: z.string().refine((val) => Number(val) <= 315360000, {
message: "Access Token TTL cannot be greater than 315360000"
}),
accessTokenMaxTTL: z.string().refine((val) => Number(val) <= 315360000, {
message: "Access Token Max TTL cannot be greater than 315360000"
}),
accessTokenNumUsesLimit: z.string(),
accessTokenTrustedIps: z
.array(
z.object({
ipAddress: z.string().max(50)
})
)
.min(1)
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["identityAuthMethod", "revokeAuthMethod"]>,
state?: boolean
) => void;
identityAuthMethodData?: {
identityId: string;
name: string;
configuredAuthMethods?: IdentityAuthMethod[];
authMethod?: IdentityAuthMethod;
};
};
export const IdentityTokenAuthForm = ({
handlePopUpOpen,
handlePopUpToggle,
identityAuthMethodData
}: Props) => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { subscription } = useSubscription();
const { mutateAsync: addMutateAsync } = useAddIdentityTokenAuth();
const { mutateAsync: updateMutateAsync } = useUpdateIdentityTokenAuth();
const isUpdate = identityAuthMethodData?.configuredAuthMethods?.includes(
identityAuthMethodData.authMethod! || ""
);
const { data } = useGetIdentityTokenAuth(identityAuthMethodData?.identityId ?? "", {
enabled: isUpdate
});
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
}
});
const {
fields: accessTokenTrustedIpsFields,
append: appendAccessTokenTrustedIp,
remove: removeAccessTokenTrustedIp
} = useFieldArray({ control, name: "accessTokenTrustedIps" });
const onFormSubmit = async ({
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
}: FormData) => {
try {
if (!identityAuthMethodData) return;
if (data) {
await updateMutateAsync({
organizationId: orgId,
identityId: identityAuthMethodData.identityId,
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
});
} else {
await addMutateAsync({
organizationId: orgId,
identityId: identityAuthMethodData.identityId,
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
});
}
handlePopUpToggle("identityAuthMethod", false);
createNotification({
text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`,
type: "success"
});
reset();
} catch {
createNotification({
text: `Failed to ${isUpdate ? "update" : "configure"} identity`,
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="2592000" type="number" min="1" step="1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenMaxTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Max TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="2592000" type="number" min="1" step="1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="0"
name="accessTokenNumUsesLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Max Number of Uses"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="0" type="number" min="0" step="1" />
</FormControl>
)}
/>
{accessTokenTrustedIpsFields.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`accessTokenTrustedIps.${index}.ipAddress`}
defaultValue="0.0.0.0/0"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Access Token Trusted IPs" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input
value={field.value}
onChange={(e) => {
if (subscription?.ipAllowlisting) {
field.onChange(e);
return;
}
handlePopUpOpen("upgradePlan");
}}
placeholder="123.456.789.0"
/>
</FormControl>
);
}}
/>
<IconButton
onClick={() => {
if (subscription?.ipAllowlisting) {
removeAccessTokenTrustedIp(index);
return;
}
handlePopUpOpen("upgradePlan");
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() => {
if (subscription?.ipAllowlisting) {
appendAccessTokenTrustedIp({
ipAddress: "0.0.0.0/0"
});
return;
}
handlePopUpOpen("upgradePlan");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add IP Address
</Button>
</div>
<div className="flex justify-between">
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{isUpdate ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("identityAuthMethod", false)}
>
Cancel
</Button>
</div>
{isUpdate && (
<Button
size="sm"
colorSchema="danger"
isLoading={isSubmitting}
isDisabled={isSubmitting}
onClick={() => handlePopUpToggle("revokeAuthMethod", true)}
>
Remove Auth Method
</Button>
)}
</div>
</form>
);
};

View File

@@ -0,0 +1,53 @@
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconButton, Modal, ModalContent, Tooltip } from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["tokenAuthToken"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["tokenAuthToken"]>, state?: boolean) => void;
};
export const IdentityTokenAuthTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
const [copyTextAccessToken, isCopyingAccessToken, setCopyTextAccessToken] = useTimedReset<string>(
{
initialState: "Copy to clipboard"
}
);
const popUpData = popUp?.tokenAuthToken?.data as {
accessToken: string;
};
return (
<Modal
isOpen={popUp?.tokenAuthToken?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("tokenAuthToken", isOpen);
}}
>
<ModalContent title="Access Token">
{popUpData?.accessToken && (
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{popUpData.accessToken}</p>
<Tooltip content={copyTextAccessToken}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(popUpData.accessToken);
setCopyTextAccessToken("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingAccessToken ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
)}
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,344 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { faCheck, faCopy, faKey, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import { format } from "date-fns";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
// DeleteActionModal,
EmptyState,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useToggle } from "@app/hooks";
import {
useCreateIdentityUniversalAuthClientSecret,
useGetIdentityUniversalAuth,
useGetIdentityUniversalAuthClientSecrets
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
description: z.string(),
ttl: z
.string()
.refine((value) => Number(value) <= 315360000, "TTL cannot be greater than 315360000"),
numUsesLimit: z.string()
});
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["universalAuthClientSecret", "revokeClientSecret"]>;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["revokeClientSecret"]>,
data?: {
clientSecretPrefix: string;
clientSecretId: string;
}
) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["universalAuthClientSecret", "revokeClientSecret"]>,
state?: boolean
) => void;
};
export const IdentityUniversalAuthClientSecretModal = ({
popUp,
handlePopUpOpen,
handlePopUpToggle
}: Props) => {
const { t } = useTranslation();
const [token, setToken] = useState("");
const [isClientSecretCopied, setIsClientSecretCopied] = useToggle(false);
const [isClientIdCopied, setIsClientIdCopied] = useToggle(false);
const popUpData = popUp?.universalAuthClientSecret?.data as {
identityId?: string;
name?: string;
};
const { data, isLoading } = useGetIdentityUniversalAuthClientSecrets(popUpData?.identityId ?? "");
const { data: identityUniversalAuth } = useGetIdentityUniversalAuth(popUpData?.identityId ?? "");
const { mutateAsync: createClientSecretMutateAsync } =
useCreateIdentityUniversalAuthClientSecret();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: yupResolver(schema),
defaultValues: {
description: "",
ttl: "",
numUsesLimit: ""
}
});
useEffect(() => {
let timer: NodeJS.Timeout;
if (isClientSecretCopied) {
timer = setTimeout(() => setIsClientSecretCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isClientSecretCopied]);
useEffect(() => {
let timer: NodeJS.Timeout;
if (isClientIdCopied) {
timer = setTimeout(() => setIsClientIdCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isClientIdCopied]);
const onFormSubmit = async ({ description, ttl, numUsesLimit }: FormData) => {
try {
if (!popUpData?.identityId) return;
const { clientSecret } = await createClientSecretMutateAsync({
identityId: popUpData.identityId,
description,
ttl: Number(ttl),
numUsesLimit: Number(numUsesLimit)
});
setToken(clientSecret);
createNotification({
text: "Successfully created client secret",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create client secret",
type: "error"
});
}
};
const hasToken = Boolean(token);
return (
<Modal
isOpen={popUp?.universalAuthClientSecret?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("universalAuthClientSecret", isOpen);
reset();
setToken("");
}}
>
<ModalContent title={`Manage Client ID/Secrets for ${popUpData?.name ?? ""}`}>
<h2 className="mb-4">Client ID</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{identityUniversalAuth?.clientId ?? ""}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(identityUniversalAuth?.clientId ?? "");
setIsClientIdCopied.on();
}}
>
<FontAwesomeIcon icon={isClientIdCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
<h2 className="mb-4">New Client Secret</h2>
{hasToken ? (
<div>
<div className="mb-4 flex items-center justify-between">
<p>We will only show this secret once</p>
<Button
colorSchema="secondary"
type="submit"
onClick={() => {
reset();
setToken("");
}}
>
Got it
</Button>
</div>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{token}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(token);
setIsClientSecretCopied.on();
}}
>
<FontAwesomeIcon icon={isClientSecretCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
</div>
) : (
<form onSubmit={handleSubmit(onFormSubmit)} className="mb-8">
<Controller
control={control}
defaultValue=""
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Description (optional)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Description" />
</FormControl>
)}
/>
<div className="flex">
<Controller
control={control}
defaultValue=""
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="TTL (seconds - optional)"
isError={Boolean(error)}
errorText={error?.message}
>
<div className="flex">
<Input {...field} placeholder="0" type="number" min="0" step="1" />
</div>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="0"
name="numUsesLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Max Number of Uses"
isError={Boolean(error)}
errorText={error?.message}
className="ml-4"
>
<div className="flex">
<Input {...field} placeholder="0" type="number" min="0" step="1" />
<Button
className="ml-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create
</Button>
</div>
</FormControl>
)}
/>
</div>
</form>
)}
<h2 className="mb-4">Client Secrets</h2>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Description</Th>
<Th>Num Uses</Th>
<Th>Expires At</Th>
<Th>Client Secret</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={5} innerKey="org-identities-client-secrets" />}
{!isLoading &&
data &&
data.length > 0 &&
data.map(
({
id,
description,
clientSecretTTL,
clientSecretPrefix,
clientSecretNumUses,
clientSecretNumUsesLimit,
createdAt
}) => {
let expiresAt;
if (clientSecretTTL > 0) {
expiresAt = new Date(new Date(createdAt).getTime() + clientSecretTTL * 1000);
}
return (
<Tr className="h-10 items-center" key={`mi-client-secret-${id}`}>
<Td>{description === "" ? "-" : description}</Td>
<Td>{`${clientSecretNumUses}${
clientSecretNumUsesLimit ? `/${clientSecretNumUsesLimit}` : ""
}`}</Td>
<Td>{expiresAt ? format(expiresAt, "yyyy-MM-dd") : "-"}</Td>
<Td>{`${clientSecretPrefix}****`}</Td>
<Td>
<IconButton
onClick={() => {
handlePopUpOpen("revokeClientSecret", {
clientSecretPrefix,
clientSecretId: id
});
}}
size="lg"
colorSchema="primary"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Td>
</Tr>
);
}
)}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState
title="No client secrets have been created for this identity yet"
icon={faKey}
/>
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,414 @@
import { useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, IconButton, Input } from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import {
useAddIdentityUniversalAuth,
useGetIdentityUniversalAuth,
useUpdateIdentityUniversalAuth
} from "@app/hooks/api";
import { IdentityAuthMethod } from "@app/hooks/api/identities";
import { IdentityTrustedIp } from "@app/hooks/api/identities/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
// TODO(rbr): check these forms validation again
const schema = z
.object({
accessTokenTTL: z
.string()
.refine(
(value) => Number(value) <= 315360000,
"Access Token TTL cannot be greater than 315360000"
),
accessTokenMaxTTL: z
.string()
.refine(
(value) => Number(value) <= 315360000,
"Access Max Token TTL cannot be greater than 315360000"
),
accessTokenNumUsesLimit: z.string(),
clientSecretTrustedIps: z
.object({
ipAddress: z.string().max(50)
})
.array()
.min(1),
accessTokenTrustedIps: z
.object({
ipAddress: z.string().max(50)
})
.array()
.min(1)
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["identityAuthMethod", "revokeAuthMethod"]>,
state?: boolean
) => void;
identityAuthMethodData?: {
identityId: string;
name: string;
configuredAuthMethods?: IdentityAuthMethod[];
authMethod?: IdentityAuthMethod;
};
};
export const IdentityUniversalAuthForm = ({
handlePopUpOpen,
handlePopUpToggle,
identityAuthMethodData
}: Props) => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { subscription } = useSubscription();
const { mutateAsync: addMutateAsync } = useAddIdentityUniversalAuth();
const { mutateAsync: updateMutateAsync } = useUpdateIdentityUniversalAuth();
const isUpdate = identityAuthMethodData?.configuredAuthMethods?.includes(
identityAuthMethodData.authMethod! || ""
);
const { data } = useGetIdentityUniversalAuth(identityAuthMethodData?.identityId ?? "", {
enabled: isUpdate
});
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: yupResolver(schema),
defaultValues: {
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
}
});
const {
fields: clientSecretTrustedIpsFields,
append: appendClientSecretTrustedIp,
remove: removeClientSecretTrustedIp
} = useFieldArray({ control, name: "clientSecretTrustedIps" });
const {
fields: accessTokenTrustedIpsFields,
append: appendAccessTokenTrustedIp,
remove: removeAccessTokenTrustedIp
} = useFieldArray({ control, name: "accessTokenTrustedIps" });
useEffect(() => {
if (data) {
reset({
accessTokenTTL: String(data.accessTokenTTL),
accessTokenMaxTTL: String(data.accessTokenMaxTTL),
accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit),
clientSecretTrustedIps: data.clientSecretTrustedIps.map(
({ ipAddress, prefix }: IdentityTrustedIp) => {
return {
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
};
}
),
accessTokenTrustedIps: data.accessTokenTrustedIps.map(
({ ipAddress, prefix }: IdentityTrustedIp) => {
return {
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
};
}
)
});
} else {
reset({
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
});
}
}, [data]);
const onFormSubmit = async ({
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
clientSecretTrustedIps,
accessTokenTrustedIps
}: FormData) => {
try {
if (!identityAuthMethodData) return;
if (data) {
// update universal auth configuration
await updateMutateAsync({
organizationId: orgId,
identityId: identityAuthMethodData.identityId,
clientSecretTrustedIps,
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
});
} else {
// create new universal auth configuration
await addMutateAsync({
organizationId: orgId,
identityId: identityAuthMethodData.identityId,
clientSecretTrustedIps,
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
});
}
handlePopUpToggle("identityAuthMethod", false);
createNotification({
text: `Successfully ${isUpdate ? "updated" : "created"} auth method`,
type: "success"
});
reset();
} catch (err) {
console.error(err);
const error = err as any;
const text =
error?.response?.data?.message ?? `Failed to ${isUpdate ? "update" : "configure"} identity`;
createNotification({
text,
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="2592000" type="number" min="1" step="1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenMaxTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Max TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="2592000" type="number" min="1" step="1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="0"
name="accessTokenNumUsesLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Max Number of Uses"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="0" type="number" min="0" step="1" />
</FormControl>
)}
/>
{clientSecretTrustedIpsFields.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`clientSecretTrustedIps.${index}.ipAddress`}
defaultValue="0.0.0.0/0"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Client Secret Trusted IPs" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input
value={field.value}
onChange={(e) => {
if (subscription?.ipAllowlisting) {
field.onChange(e);
return;
}
handlePopUpOpen("upgradePlan");
}}
placeholder="123.456.789.0"
/>
</FormControl>
);
}}
/>
<IconButton
onClick={() => {
if (subscription?.ipAllowlisting) {
removeClientSecretTrustedIp(index);
return;
}
handlePopUpOpen("upgradePlan");
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() => {
if (subscription?.ipAllowlisting) {
appendClientSecretTrustedIp({
ipAddress: "0.0.0.0/0"
});
return;
}
handlePopUpOpen("upgradePlan");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add IP Address
</Button>
</div>
{accessTokenTrustedIpsFields.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`accessTokenTrustedIps.${index}.ipAddress`}
defaultValue="0.0.0.0/0"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Access Token Trusted IPs" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input
value={field.value}
onChange={(e) => {
if (subscription?.ipAllowlisting) {
field.onChange(e);
return;
}
handlePopUpOpen("upgradePlan");
}}
placeholder="123.456.789.0"
/>
</FormControl>
);
}}
/>
<IconButton
onClick={() => {
if (subscription?.ipAllowlisting) {
removeAccessTokenTrustedIp(index);
return;
}
handlePopUpOpen("upgradePlan");
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() => {
if (subscription?.ipAllowlisting) {
appendAccessTokenTrustedIp({
ipAddress: "0.0.0.0/0"
});
return;
}
handlePopUpOpen("upgradePlan");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add IP Address
</Button>
</div>
<div className="flex justify-between">
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{isUpdate ? "Edit" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("identityAuthMethod", false)}
>
Cancel
</Button>
</div>
{isUpdate && (
<Button
size="sm"
colorSchema="danger"
isLoading={isSubmitting}
isDisabled={isSubmitting}
onClick={() => handlePopUpToggle("revokeAuthMethod", true)}
>
Delete Auth Method
</Button>
)}
</div>
</form>
);
};

View File

@@ -0,0 +1 @@
export { IdentitySection } from "./IdentitySection";

View File

@@ -0,0 +1 @@
export { OrgIdentityTab } from "./OrgIdentityTab";

View File

@@ -0,0 +1,17 @@
import { motion } from "framer-motion";
import { OrgMembersSection } from "./components";
export const OrgMembersTab = () => {
return (
<motion.div
key="panel-org-members"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<OrgMembersSection />
</motion.div>
);
};

View File

@@ -0,0 +1,321 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Modal,
ModalContent,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { findOrgMembershipRole } from "@app/helpers/roles";
import {
useAddUsersToOrg,
useFetchServerStatus,
useGetOrgRoles,
useGetUserWorkspaces
} from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { OrgInviteLink } from "./OrgInviteLink";
const DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG = "member";
const EmailSchema = z.string().email().min(1).trim().toLowerCase();
const addMemberFormSchema = z.object({
emails: z.string().min(1).trim().toLowerCase(),
projects: z
.array(
z.object({
name: z.string(),
id: z.string(),
slug: z.string(),
version: z.nativeEnum(ProjectVersion)
})
)
.default([]),
projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG),
organizationRole: z.object({ name: z.string(), slug: z.string() })
});
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
type Props = {
popUp: UsePopUpState<["addMember"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addMember"]>, state?: boolean) => void;
completeInviteLinks: Array<{
email: string;
link: string;
}> | null;
setCompleteInviteLinks: (links: Array<{ email: string; link: string }> | null) => void;
};
export const AddOrgMemberModal = ({
popUp,
handlePopUpToggle,
completeInviteLinks,
setCompleteInviteLinks
}: Props) => {
const { currentOrg } = useOrganization();
const { data: organizationRoles } = useGetOrgRoles(currentOrg?.id ?? "");
const { data: serverDetails } = useFetchServerStatus();
const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg();
const { data: projects, isLoading: isProjectsLoading } = useGetUserWorkspaces({
includeRoles: true
});
const {
control,
handleSubmit,
watch,
reset,
formState: { isSubmitting }
} = useForm<TAddMemberForm>({
resolver: zodResolver(addMemberFormSchema)
});
// set initial form role based off org default role
useEffect(() => {
if (organizationRoles) {
reset({
organizationRole: findOrgMembershipRole(organizationRoles, currentOrg.defaultMembershipRole)
});
}
}, [organizationRoles]);
const onAddMembers = async ({
emails,
organizationRole,
projects: selectedProjects,
projectRoleSlug
}: TAddMemberForm) => {
if (!currentOrg?.id) return;
if (selectedProjects?.length) {
// eslint-disable-next-line no-restricted-syntax
for (const project of selectedProjects) {
if (project.version !== ProjectVersion.V3) {
createNotification({
type: "error",
text: `Cannot add users to project "${project.name}" because it's incompatible. Please upgrade the project.`
});
return;
}
}
}
try {
const parsedEmails = emails
.replace(/\s/g, "")
.split(",")
.map((email) => {
if (EmailSchema.safeParse(email).success) {
return email.trim();
}
return null;
});
if (parsedEmails.includes(null)) {
createNotification({
text: "Invalid email addresses provided.",
type: "error"
});
return;
}
const { data } = await addUsersMutateAsync({
organizationId: currentOrg?.id,
inviteeEmails: emails.split(",").map((email) => email.trim()),
organizationRoleSlug: organizationRole.slug,
projects: selectedProjects.map(({ id }) => ({ id, projectRoleSlug: [projectRoleSlug] }))
});
setCompleteInviteLinks(data?.completeInviteLinks ?? null);
// only show this notification when email is configured.
// A [completeInviteLink] will not be sent if smtp is configured
if (!data.completeInviteLinks) {
createNotification({
text: "Successfully invited user to the organization.",
type: "success"
});
}
} catch (error) {
console.error(error);
createNotification({
text: "Failed to invite user to org",
type: "error"
});
}
if (serverDetails?.emailConfigured) {
handlePopUpToggle("addMember", false);
}
reset();
};
return (
<Modal
isOpen={popUp?.addMember?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addMember", isOpen);
setCompleteInviteLinks(null);
}}
>
<ModalContent
bodyClassName="overflow-visible"
title={`Invite others to ${currentOrg?.name}`}
subTitle={
<div>
{!completeInviteLinks && (
<div>An invite is specific to an email address and expires after 1 day.</div>
)}
{completeInviteLinks &&
"This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"}
</div>
}
>
{!completeInviteLinks && (
<form onSubmit={handleSubmit(onAddMembers)}>
<Controller
control={control}
name="emails"
render={({ field, fieldState: { error } }) => (
<FormControl label="Emails" isError={Boolean(error)} errorText={error?.message}>
<TextArea
{...field}
className="mt-1 h-20 w-full min-w-[30rem] rounded-md border border-mineshaft-500 bg-mineshaft-900/70 px-2 py-1 text-sm text-bunker-300 outline-none ring-primary-800 ring-opacity-70 transition-all placeholder:text-bunker-400 focus:ring-2"
placeholder="email@example.com, email2@example.com..."
/>
</FormControl>
)}
/>
<Controller
control={control}
name="organizationRole"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText="Select which organization role you want to assign to the user."
label="Assign organization role"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
placeholder="Select role..."
options={organizationRoles}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<div className="flex items-start justify-between gap-2">
<div className="w-full">
<Controller
control={control}
name="projects"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Assign users to projects"
isOptional
isError={Boolean(error?.message)}
errorText={error?.message}
>
<FilterableSelect
isMulti
value={value}
onChange={onChange}
isLoading={isProjectsLoading}
getOptionLabel={(project) => project.name}
getOptionValue={(project) => project.id}
options={projects}
placeholder="Select projects..."
/>
</FormControl>
)}
/>
</div>
<div className="mt-[0.15rem] flex min-w-fit justify-end">
<Controller
control={control}
name="projectRoleSlug"
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="Select which role to assign to the users in the selected projects."
label="Role"
isError={Boolean(error)}
errorText={error?.message}
>
<div>
<Select
isDisabled={watch("projects", []).length === 0}
defaultValue={DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG}
{...field}
onValueChange={(val) => field.onChange(val)}
>
{Object.entries(ProjectMembershipRole).map(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
([_, slug]) =>
slug !== "custom" && (
<SelectItem key={slug} value={slug}>
<span className="capitalize">{slug.replace("-", " ")}</span>
</SelectItem>
)
)}
</Select>
</div>
</FormControl>
)}
/>
</div>
</div>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Add Member
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("addMember", false)}
>
Cancel
</Button>
</div>
</form>
)}
{completeInviteLinks && (
<div className="space-y-3">
{completeInviteLinks.map((invite) => (
<OrgInviteLink key={`invite-${invite.email}`} invite={invite} />
))}
</div>
)}
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,55 @@
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { IconButton, Tooltip } from "@app/components/v2";
import { useToggle } from "@app/hooks";
type Props = {
invite: { email: string; link: string };
};
export const OrgInviteLink = ({ invite }: Props) => {
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
const copyTokenToClipboard = () => {
if (isInviteLinkCopied) return;
navigator.clipboard.writeText(invite.link);
setInviteLinkCopied.timedToggle();
createNotification({
type: "info",
text: "Copied invitation link to clipboard"
});
};
return (
<div key={`invite-${invite.email}`}>
<p className="text-sm text-mineshaft-400">
Invite for <span className="font-medium">{invite.email}</span>
</p>
<div className="flex flex-col gap-1 rounded-md bg-white/[0.04] p-2 text-base text-gray-400">
<p
className={twMerge(
"mr-4 line-clamp-1",
window.isSecureContext ? "overflow-hidden text-ellipsis whitespace-nowrap" : "break-all"
)}
>
{invite.link}
</p>
<Tooltip content={`Copy invitation link for ${invite.email}`}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => copyTokenToClipboard()}
>
<FontAwesomeIcon icon={isInviteLinkCopied ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
</div>
);
};

View File

@@ -0,0 +1,186 @@
import { useState } from "react";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
EmailServiceSetupModal,
UpgradePlanModal
} from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} from "@app/context";
import { useDeleteOrgMembership, useUpdateOrgMembership } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { AddOrgMemberModal } from "./AddOrgMemberModal";
import { OrgMembersTable } from "./OrgMembersTable";
export const OrgMembersSection = () => {
const { subscription } = useSubscription();
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id ?? "";
const [completeInviteLinks, setCompleteInviteLinks] = useState<Array<{
email: string;
link: string;
}> | null>(null);
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addMember",
"removeMember",
"deactivateMember",
"upgradePlan",
"setUpEmail"
] as const);
const { mutateAsync: deleteMutateAsync } = useDeleteOrgMembership();
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
const isMoreUsersAllowed = subscription?.memberLimit
? subscription.membersUsed < subscription.memberLimit
: true;
const isMoreIdentitiesAllowed = subscription?.identityLimit
? subscription.identitiesUsed < subscription.identityLimit
: true;
const isEnterprise = subscription?.slug === "enterprise";
const handleAddMemberModal = () => {
if (currentOrg?.authEnforced) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
});
return;
}
if ((!isMoreUsersAllowed || !isMoreIdentitiesAllowed) && !isEnterprise) {
handlePopUpOpen("upgradePlan", {
description: "You can add more members if you upgrade your Infisical plan."
});
return;
}
handlePopUpOpen("addMember");
};
const onDeactivateMemberSubmit = async (orgMembershipId: string) => {
try {
await updateOrgMembership({
organizationId: orgId,
membershipId: orgMembershipId,
isActive: false
});
createNotification({
text: "Successfully deactivated user in organization",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to deactivate user in organization",
type: "error"
});
}
handlePopUpClose("deactivateMember");
};
const onRemoveMemberSubmit = async (orgMembershipId: string) => {
try {
await deleteMutateAsync({
orgId,
membershipId: orgMembershipId
});
createNotification({
text: "Successfully removed user from org",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to remove user from the organization",
type: "error"
});
}
handlePopUpClose("removeMember");
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Users</p>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Member}>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handleAddMemberModal()}
isDisabled={!isAllowed}
>
Add Member
</Button>
)}
</OrgPermissionCan>
</div>
<OrgMembersTable
handlePopUpOpen={handlePopUpOpen}
setCompleteInviteLinks={setCompleteInviteLinks}
/>
<AddOrgMemberModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
completeInviteLinks={completeInviteLinks}
setCompleteInviteLinks={setCompleteInviteLinks}
/>
<DeleteActionModal
isOpen={popUp.removeMember.isOpen}
title={`Are you sure want to remove member with username ${
(popUp?.removeMember?.data as { username: string })?.username || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveMemberSubmit(
(popUp?.removeMember?.data as { orgMembershipId: string })?.orgMembershipId
)
}
/>
<DeleteActionModal
isOpen={popUp.deactivateMember.isOpen}
title={`Are you sure want to deactivate member with username ${
(popUp?.deactivateMember?.data as { username: string })?.username || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deactivateMember", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onDeactivateMemberSubmit(
(popUp?.deactivateMember?.data as { orgMembershipId: string })?.orgMembershipId
)
}
buttonText="Deactivate"
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
<EmailServiceSetupModal
isOpen={popUp.setUpEmail?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
/>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More