mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat: resolved breaking dev
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { CreateOrgModal } from "./CreateOrgModal";
|
||||
10
frontend-v2/src/hooks/api/secretScanning/index.tsx
Normal file
10
frontend-v2/src/hooks/api/secretScanning/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
useCreateNewInstallationSession,
|
||||
useLinkGitAppInstallationWithOrg,
|
||||
useUpdateRiskStatus
|
||||
} from "./mutation";
|
||||
export {
|
||||
secretScanningQueryKeys,
|
||||
useGetSecretScanningInstallationStatus,
|
||||
useGetSecretScanningRisks
|
||||
} from "./queries";
|
||||
45
frontend-v2/src/hooks/api/secretScanning/mutation.ts
Normal file
45
frontend-v2/src/hooks/api/secretScanning/mutation.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
};
|
||||
34
frontend-v2/src/hooks/api/secretScanning/queries.ts
Normal file
34
frontend-v2/src/hooks/api/secretScanning/queries.ts
Normal 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)
|
||||
});
|
||||
51
frontend-v2/src/hooks/api/secretScanning/types.ts
Normal file
51
frontend-v2/src/hooks/api/secretScanning/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export { OrgAdminProjects } from "./OrgAdminProjects";
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 }
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { LogsSection } from "./LogsSection";
|
||||
@@ -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;
|
||||
@@ -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
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { CurrentPlanSection } from "./CurrentPlanSection";
|
||||
import { PreviewSection } from "./PreviewSection";
|
||||
|
||||
export const BillingCloudTab = () => {
|
||||
return (
|
||||
<div>
|
||||
<PreviewSection />
|
||||
<CurrentPlanSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 →
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { BillingCloudTab } from "./BillingCloudTab";
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { BillingDetailsTab } from "./BillingDetailsTab";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { BillingReceiptsTab } from "./BillingReceiptsTab";
|
||||
@@ -0,0 +1,9 @@
|
||||
import { LicensesSection } from "./LicensesSection";
|
||||
|
||||
export const BillingSelfHostedTab = () => {
|
||||
return (
|
||||
<div>
|
||||
<LicensesSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./BillingSelfHostedTab";
|
||||
@@ -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 }
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export { BillingTabGroup } from "./BillingTabGroup";
|
||||
@@ -0,0 +1 @@
|
||||
export { BillingTabGroup } from "./BillingTabGroup";
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { GroupMembersSection } from "./GroupMembersSection";
|
||||
@@ -0,0 +1 @@
|
||||
export { GroupDetailsSection } from "./GroupDetailsSection";
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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 />
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { IdentityAuthenticationSection } from "./IdentityAuthenticationSection";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 />
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { IdentityProjectsSection } from "./IdentityProjectsSection";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { OrgGroupsSection } from "./OrgGroupsSection";
|
||||
@@ -0,0 +1 @@
|
||||
export { OrgGroupsSection } from "./OrgGroupsSection";
|
||||
@@ -0,0 +1 @@
|
||||
export { OrgGroupsTab } from "./OrgGroupsTab";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 }
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { IdentitySection } from "./IdentitySection";
|
||||
@@ -0,0 +1 @@
|
||||
export { IdentitySection } from "./IdentitySection";
|
||||
@@ -0,0 +1 @@
|
||||
export { OrgIdentityTab } from "./OrgIdentityTab";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user