merge main

This commit is contained in:
Scott Wilson
2025-06-02 16:24:55 -07:00
722 changed files with 30947 additions and 13097 deletions
@@ -0,0 +1,27 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { AuthenticationPageForm } from "./components";
export const AuthenticationPage = () => {
const { t } = useTranslation();
return (
<div className="h-full bg-bunker-800">
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader
title="Authentication"
description="Manage authentication settings for your Infisical instance."
/>
<AuthenticationPageForm />
</div>
</div>
</div>
);
};
@@ -20,7 +20,7 @@ const formSchema = z.object({
type TAuthForm = z.infer<typeof formSchema>;
export const AuthPanel = () => {
export const AuthenticationPageForm = () => {
const { config } = useServerConfig();
const { enabledLoginMethods } = config;
const { mutateAsync: updateServerConfig } = useUpdateServerConfig();
@@ -0,0 +1 @@
export { AuthenticationPageForm } from "./AuthenticationPageForm";
@@ -0,0 +1,25 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { AuthenticationPage } from "./AuthenticationPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/admin/_admin-layout/authentication"
)({
component: AuthenticationPage,
beforeLoad: async () => {
return {
breadcrumbs: [
{
label: "Admin",
link: linkOptions({ to: "/admin" })
},
{
label: "Authentication",
link: linkOptions({
to: "/admin/authentication"
})
}
]
};
}
});
@@ -0,0 +1,24 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { CachingPageForm } from "./components";
export const CachingPage = () => {
const { t } = useTranslation();
return (
<div className="h-full bg-bunker-800">
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader title="Caching" description="Manage caching for your Infisical instance." />
<CachingPageForm />
</div>
</div>
</div>
);
};
@@ -10,7 +10,7 @@ import { useInvalidateCache } from "@app/hooks/api";
import { useGetInvalidatingCacheStatus } from "@app/hooks/api/admin/queries";
import { CacheType } from "@app/hooks/api/admin/types";
export const CachingPanel = () => {
export const CachingPageForm = () => {
const { mutateAsync: invalidateCache } = useInvalidateCache();
const { user } = useUser();
@@ -0,0 +1 @@
export { CachingPageForm } from "./CachingPageForm";
@@ -0,0 +1,25 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { CachingPage } from "./CachingPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/admin/_admin-layout/caching"
)({
component: CachingPage,
beforeLoad: async () => {
return {
breadcrumbs: [
{
label: "Admin",
link: linkOptions({ to: "/admin" })
},
{
label: "Caching",
link: linkOptions({
to: "/admin/caching"
})
}
]
};
}
});
@@ -0,0 +1,27 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { EncryptionPageForm } from "./components";
export const EncryptionPage = () => {
const { t } = useTranslation();
return (
<div className="h-full bg-bunker-800">
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader
title="Encryption"
description="Manage encryption settings for your Infisical instance."
/>
<EncryptionPageForm />
</div>
</div>
</div>
);
};
@@ -8,11 +8,11 @@ import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Select, SelectItem } from "@app/components/v2";
import { useSubscription } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useUpdateServerEncryptionStrategy } from "@app/hooks/api";
import {
RootKeyEncryptionStrategy,
TGetServerRootKmsEncryptionDetails
} from "@app/hooks/api/admin/types";
useGetServerRootKmsEncryptionDetails,
useUpdateServerEncryptionStrategy
} from "@app/hooks/api";
import { RootKeyEncryptionStrategy } from "@app/hooks/api/admin/types";
const formSchema = z.object({
encryptionStrategy: z.nativeEnum(RootKeyEncryptionStrategy)
@@ -25,11 +25,9 @@ const strategies: Record<RootKeyEncryptionStrategy, string> = {
type TForm = z.infer<typeof formSchema>;
type Props = {
rootKmsDetails?: TGetServerRootKmsEncryptionDetails;
};
export const EncryptionPageForm = () => {
const { data: rootKmsDetails } = useGetServerRootKmsEncryptionDetails();
export const EncryptionPanel = ({ rootKmsDetails }: Props) => {
const { mutateAsync: updateEncryptionStrategy } = useUpdateServerEncryptionStrategy();
const { subscription } = useSubscription();
@@ -0,0 +1 @@
export { EncryptionPageForm } from "./EncryptionPageForm";
@@ -0,0 +1,25 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { EncryptionPage } from "./EncryptionPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/admin/_admin-layout/encryption"
)({
component: EncryptionPage,
beforeLoad: async () => {
return {
breadcrumbs: [
{
label: "Admin",
link: linkOptions({ to: "/admin" })
},
{
label: "Encryption",
link: linkOptions({
to: "/admin/encryption"
})
}
]
};
}
});
@@ -0,0 +1,27 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { GeneralPageForm } from "./components";
export const GeneralPage = () => {
const { t } = useTranslation();
return (
<div className="h-full bg-bunker-800">
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader
title="General"
description="Manage general settings for your Infisical instance."
/>
<GeneralPageForm />
</div>
</div>
</div>
);
};
@@ -0,0 +1,309 @@
import { Controller, useForm } from "react-hook-form";
import { faAt } 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,
Input,
Select,
SelectClear,
SelectItem,
Switch,
TextArea
} from "@app/components/v2";
import { useServerConfig } from "@app/context";
import { useGetOrganizations, useUpdateServerConfig } from "@app/hooks/api";
enum SignUpModes {
Disabled = "disabled",
Anyone = "anyone"
}
const formSchema = z.object({
signUpMode: z.nativeEnum(SignUpModes),
allowedSignUpDomain: z.string().optional().nullable(),
trustSamlEmails: z.boolean(),
trustLdapEmails: z.boolean(),
trustOidcEmails: z.boolean(),
defaultAuthOrgId: z.string(),
authConsentContent: z.string().optional().default(""),
pageFrameContent: z.string().optional().default("")
});
type TDashboardForm = z.infer<typeof formSchema>;
export const GeneralPageForm = () => {
const data = useServerConfig();
const { config } = data;
const {
control,
handleSubmit,
watch,
formState: { isSubmitting, isDirty }
} = useForm<TDashboardForm>({
resolver: zodResolver(formSchema),
values: {
// eslint-disable-next-line
signUpMode: config.allowSignUp ? SignUpModes.Anyone : SignUpModes.Disabled,
allowedSignUpDomain: config.allowedSignUpDomain,
trustSamlEmails: config.trustSamlEmails,
trustLdapEmails: config.trustLdapEmails,
trustOidcEmails: config.trustOidcEmails,
defaultAuthOrgId: config.defaultAuthOrgId ?? "",
authConsentContent: config.authConsentContent ?? "",
pageFrameContent: config.pageFrameContent ?? ""
}
});
const signUpMode = watch("signUpMode");
const defaultAuthOrgId = watch("defaultAuthOrgId");
const { mutateAsync: updateServerConfig } = useUpdateServerConfig();
const organizations = useGetOrganizations();
const onFormSubmit = async (formData: TDashboardForm) => {
try {
const {
allowedSignUpDomain,
trustSamlEmails,
trustLdapEmails,
trustOidcEmails,
authConsentContent,
pageFrameContent
} = formData;
await updateServerConfig({
defaultAuthOrgId: defaultAuthOrgId || null,
allowSignUp: signUpMode !== SignUpModes.Disabled,
allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null,
trustSamlEmails,
trustLdapEmails,
trustOidcEmails,
authConsentContent,
pageFrameContent
});
createNotification({
text: "Successfully changed sign up setting.",
type: "success"
});
} catch (e) {
console.error(e);
createNotification({
type: "error",
text: "Failed to update sign up setting."
});
}
};
return (
<form
className="space-y-8 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
onSubmit={handleSubmit(onFormSubmit)}
>
<div className="flex flex-col justify-start">
<div className="mb-2 text-xl font-semibold text-mineshaft-100">Allow user signups</div>
<div className="mb-4 max-w-sm text-sm text-mineshaft-400">
Select if you want users to be able to signup freely into your Infisical instance.
</div>
<Controller
control={control}
name="signUpMode"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl className="max-w-sm" errorText={error?.message} isError={Boolean(error)}>
<Select
className="w-full bg-mineshaft-700"
dropdownContainerClassName="bg-mineshaft-800"
defaultValue={field.value}
onValueChange={(e) => onChange(e)}
{...field}
>
<SelectItem value={SignUpModes.Disabled}>Disabled</SelectItem>
<SelectItem value={SignUpModes.Anyone}>Anyone</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
{signUpMode === "anyone" && (
<div className="flex flex-col justify-start">
<div className="mb-4 text-xl font-semibold text-mineshaft-100">
Restrict signup by email domain(s)
</div>
<Controller
control={control}
defaultValue=""
name="allowedSignUpDomain"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Leave blank to allow any email domains"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
placeholder="gmail.com, aws.com, redhat.com"
leftIcon={<FontAwesomeIcon icon={faAt} />}
/>
</FormControl>
)}
/>
</div>
)}
<div className="flex flex-col justify-start">
<div className="mb-2 text-xl font-semibold text-mineshaft-100">Default organization</div>
<div className="mb-4 max-w-sm text-sm text-mineshaft-400">
Select the default organization you want to set for SAML/LDAP/OIDC/Github logins. When
selected, user logins will be automatically scoped to the selected organization.
</div>
<Controller
control={control}
name="defaultAuthOrgId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl className="max-w-sm" errorText={error?.message} isError={Boolean(error)}>
<Select
placeholder="Allow all organizations"
className="w-full bg-mineshaft-700"
dropdownContainerClassName="bg-mineshaft-800"
defaultValue={field.value ?? " "}
onValueChange={(e) => onChange(e)}
{...field}
>
<SelectClear
selectValue={defaultAuthOrgId}
onClear={() => {
onChange("");
}}
>
Allow all organizations
</SelectClear>
{organizations.data?.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div className="flex flex-col justify-start">
<div className="mb-2 text-xl font-semibold text-mineshaft-100">Trust emails</div>
<div className="mb-4 max-w-sm text-sm text-mineshaft-400">
Select if you want Infisical to trust external emails from SAML/LDAP/OIDC identity
providers. If set to false, then Infisical will prompt SAML/LDAP/OIDC provisioned users to
verify their email upon their first login.
</div>
<Controller
control={control}
name="trustSamlEmails"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="trust-saml-emails"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Trust SAML emails</p>
</Switch>
</FormControl>
);
}}
/>
<Controller
control={control}
name="trustLdapEmails"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="trust-ldap-emails"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Trust LDAP emails</p>
</Switch>
</FormControl>
);
}}
/>
<Controller
control={control}
name="trustOidcEmails"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="trust-oidc-emails"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Trust OIDC emails</p>
</Switch>
</FormControl>
);
}}
/>
</div>
<div className="flex flex-col justify-start">
<div className="mb-2 text-xl font-semibold text-mineshaft-100">Notices</div>
<div className="mb-4 max-w-lg text-sm text-mineshaft-400">
Configure system-wide notification banners and security messages. These settings control
the text displayed to users during authentication and throughout their session
</div>
<Controller
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Auth Consent Content"
tooltipText="Formatting supported: HTML, Markdown, Plain text"
>
<TextArea
placeholder="**Auth Consent Message**"
{...field}
rows={3}
className="thin-scrollbar h-48 max-w-lg !resize-none bg-mineshaft-800"
/>
</FormControl>
)}
control={control}
name="authConsentContent"
/>
<Controller
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Page Frame Content"
tooltipText="Formatting supported: HTML, Markdown, Plain text"
>
<TextArea
placeholder='<div style="background-color: red">TOP SECRET</div>'
{...field}
rows={3}
className="thin-scrollbar h-48 max-w-lg !resize-none bg-mineshaft-800"
/>
</FormControl>
)}
control={control}
name="pageFrameContent"
/>
</div>
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting || !isDirty}>
Save
</Button>
</form>
);
};
@@ -0,0 +1 @@
export { GeneralPageForm } from "./GeneralPageForm";
@@ -0,0 +1,23 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { GeneralPage } from "./GeneralPage";
export const Route = createFileRoute("/_authenticate/_inject-org-details/admin/_admin-layout/")({
component: GeneralPage,
beforeLoad: async () => {
return {
breadcrumbs: [
{
label: "Admin",
link: linkOptions({ to: "/admin" })
},
{
label: "General",
link: linkOptions({
to: "/admin"
})
}
]
};
}
});
@@ -0,0 +1,27 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { IntegrationsPageForm } from "./components";
export const IntegrationsPage = () => {
const { t } = useTranslation();
return (
<div className="h-full bg-bunker-800">
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader
title="Integrations"
description="Manage integrations for your Infisical instance."
/>
<IntegrationsPageForm />
</div>
</div>
</div>
);
};
@@ -3,7 +3,7 @@ import { useGetAdminIntegrationsConfig } from "@app/hooks/api";
import { MicrosoftTeamsIntegrationForm } from "./MicrosoftTeamsIntegrationForm";
import { SlackIntegrationForm } from "./SlackIntegrationForm";
export const IntegrationPanel = () => {
export const IntegrationsPageForm = () => {
const { data: adminIntegrationsConfig } = useGetAdminIntegrationsConfig();
return (
@@ -0,0 +1 @@
export { IntegrationsPageForm } from "./IntegrationsPageForm";
@@ -0,0 +1,25 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { IntegrationsPage } from "./IntegrationsPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/admin/_admin-layout/integrations"
)({
component: IntegrationsPage,
beforeLoad: async () => {
return {
breadcrumbs: [
{
label: "Admin",
link: linkOptions({ to: "/admin" })
},
{
label: "Integrations",
link: linkOptions({
to: "/admin/integrations"
})
}
]
};
}
});
@@ -0,0 +1,27 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { MachineIdentitiesTable } from "./components";
export const MachineIdentitiesResourcesPage = () => {
const { t } = useTranslation();
return (
<div className="h-full bg-bunker-800">
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader
title="Machine Identities"
description="Manage all machine identities within your Infisical instance."
/>
<MachineIdentitiesTable />
</div>
</div>
</div>
);
};
@@ -120,7 +120,7 @@ const IdentityPanelTable = ({
<Button
className="mt-4 py-3 text-sm"
isFullWidth
variant="star"
variant="outline_bg"
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()}
@@ -133,7 +133,7 @@ const IdentityPanelTable = ({
);
};
export const IdentityPanel = () => {
export const MachineIdentitiesTable = () => {
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"removeServerAdmin"
] as const);
@@ -161,9 +161,6 @@ export const IdentityPanel = () => {
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
</div>
<IdentityPanelTable handlePopUpOpen={handlePopUpOpen} />
<DeleteActionModal
isOpen={popUp.removeServerAdmin.isOpen}
@@ -0,0 +1 @@
export { MachineIdentitiesTable } from "./MachineIdentitiesTable";
@@ -0,0 +1,25 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { MachineIdentitiesResourcesPage } from "./MachineIdentitiesResourcesPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities"
)({
component: MachineIdentitiesResourcesPage,
beforeLoad: async () => {
return {
breadcrumbs: [
{
label: "Admin",
link: linkOptions({ to: "/admin" })
},
{
label: "Machine Identities",
link: linkOptions({
to: "/admin/resources/machine-identities"
})
}
]
};
}
});
@@ -0,0 +1,27 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { OrganizationsTable } from "./components";
export const OrganizationResourcesPage = () => {
const { t } = useTranslation();
return (
<div className="h-full bg-bunker-800">
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader
title="Organizations"
description="Manage all organizations within your Infisical instance."
/>
<OrganizationsTable />
</div>
</div>
</div>
);
};
@@ -0,0 +1,396 @@
import { useState } from "react";
import {
faBuilding,
faCircleQuestion,
faEllipsis,
faMagnifyingGlass
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import {
Badge,
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Input,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useDebounce, usePopUp } from "@app/hooks";
import {
useAdminDeleteOrganization,
useAdminDeleteOrganizationMembership,
useAdminDeleteUser,
useAdminGetOrganizations
} from "@app/hooks/api";
import { OrganizationWithProjects } from "@app/hooks/api/admin/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
const ViewMembersModalContent = ({
popUp,
handlePopUpOpen
}: {
popUp: UsePopUpState<["viewMembers"]>;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>,
data?: {
username?: string;
membershipId?: string;
userId?: string;
orgName?: string;
orgId?: string;
organization?: OrganizationWithProjects;
}
) => void;
}) => {
const organization = popUp.viewMembers?.data?.organization as OrganizationWithProjects;
return (
<div className="space-y-2">
{organization?.members?.map((member) => (
<div className="flex items-center justify-between gap-2 rounded-md bg-mineshaft-700 px-4 py-2">
<div>
<div className="flex items-center gap-2">
<p className="text-xs text-mineshaft-100">
<div>
{member.user.firstName ? (
<div>
{member.user.firstName} {member.user.lastName}
</div>
) : (
<p className="text-mineshaft-400">Not set</p>
)}
</div>
<div className="flex gap-2 opacity-80">
<div>{member.user.username || member.user.email}</div>
<Badge variant="primary">
<div className="flex items-center gap-1">
<span className="capitalize">{member.role.replace("-", " ")}</span>
{Boolean(member.roleId) && (
<Tooltip content="This member has a custom role assigned.">
<FontAwesomeIcon icon={faCircleQuestion} className="text-xs" />
</Tooltip>
)}
</div>
</Badge>
</div>
</p>
</div>
</div>
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<FontAwesomeIcon
icon={faEllipsis}
className="cursor-pointer text-sm text-mineshaft-400 transition-all hover:text-primary-500"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuItem
onClick={() =>
handlePopUpOpen("deleteOrganizationMembership", {
membershipId: member.membershipId,
orgId: organization.id,
username: member.user.username,
orgName: organization.name
})
}
>
Remove From Organization
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handlePopUpOpen("deleteUser", { userId: member.user.id })}
>
Delete User
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
);
};
const ViewMembersModal = ({
isOpen,
onOpenChange,
popUp,
handlePopUpOpen
}: {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
popUp: UsePopUpState<["viewMembers", "deleteOrganizationMembership", "deleteUser"]>;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>
) => void;
}) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
onOpenAutoFocus={(event) => {
event.preventDefault();
}}
title="Organization Members"
subTitle="View the members of the organization."
>
<ViewMembersModalContent popUp={popUp} handlePopUpOpen={handlePopUpOpen} />
</ModalContent>
</Modal>
);
};
const OrganizationsPanelTable = ({
popUp,
handlePopUpOpen,
handlePopUpToggle
}: {
popUp: UsePopUpState<
["deleteOrganization", "viewMembers", "deleteOrganizationMembership", "deleteUser"]
>;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<
["deleteOrganization", "viewMembers", "deleteOrganizationMembership", "deleteUser"]
>,
data?: {
orgName?: string;
orgId?: string;
message?: string;
organization?: OrganizationWithProjects;
}
) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["deleteOrganization", "viewMembers"]>,
isOpen?: boolean
) => void;
}) => {
const [searchOrganizationsFilter, setSearchOrganizationsFilter] = useState("");
const [debouncedSearchTerm] = useDebounce(searchOrganizationsFilter, 500);
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } =
useAdminGetOrganizations({
limit: 20,
searchTerm: debouncedSearchTerm
});
const isEmpty = !isPending && !data?.pages?.[0].length;
return (
<>
<div className="flex gap-2">
<Input
value={searchOrganizationsFilter}
onChange={(e) => setSearchOrganizationsFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search organizations..."
className="flex-1"
/>
</div>
<div className="mt-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-5/12">Name</Th>
<Th className="w-5/12">Members</Th>
<Th className="w-5/12">Projects</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="organizations" />}
{!isPending &&
data?.pages?.map((orgs) =>
orgs.map((org) => {
return (
<Tr key={`org-${org.id}`} className="w-full">
<Td className="w-5/12">
{org.name ? (
org.name
) : (
<span className="text-mineshaft-400">Not set</span>
)}
</Td>
<Td className="w-5/12">
{org.members.length} {org.members.length === 1 ? "member" : "members"}
<Button
variant="outline_bg"
size="xs"
className="ml-2"
onClick={() => handlePopUpOpen("viewMembers", { organization: org })}
>
View Members
</Button>
</Td>
<Td className="w-5/12">
{org.projects.length} {org.projects.length === 1 ? "project" : "projects"}
</Td>
<Td>
<div className="flex justify-end">
<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();
handlePopUpOpen("deleteOrganization", {
orgId: org.id,
orgName: org.name
});
}}
>
Delete Organization
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Td>
</Tr>
);
})
)}
</TBody>
</Table>
{!isPending && isEmpty && <EmptyState title="No organizations found" icon={faBuilding} />}
</TableContainer>
{!isEmpty && (
<Button
className="mt-4 py-3 text-sm"
isFullWidth
variant="outline_bg"
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()}
>
{hasNextPage ? "Load More" : "End of list"}
</Button>
)}
</div>
<ViewMembersModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
isOpen={popUp.viewMembers.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("viewMembers", isOpen)}
/>
</>
);
};
export const OrganizationsTable = () => {
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"deleteOrganization",
"deleteOrganizationMembership",
"deleteUser",
"viewMembers"
] as const);
const { mutateAsync: deleteOrganization } = useAdminDeleteOrganization();
const { mutateAsync: deleteOrganizationMembership } = useAdminDeleteOrganizationMembership();
const { mutateAsync: deleteUser } = useAdminDeleteUser();
const handleDeleteOrganization = async () => {
const { orgId } = popUp?.deleteOrganization?.data as { orgId: string };
await deleteOrganization(orgId);
createNotification({
type: "success",
text: "Successfully deleted organization"
});
handlePopUpClose("deleteOrganization");
};
const handleDeleteOrganizationMembership = async () => {
const { orgId, membershipId } = popUp?.deleteOrganizationMembership?.data as {
orgId: string;
membershipId: string;
};
if (!orgId || !membershipId) {
return;
}
await deleteOrganizationMembership({ organizationId: orgId, membershipId });
createNotification({
type: "success",
text: "Successfully removed user from organization"
});
handlePopUpClose("viewMembers");
handlePopUpClose("deleteOrganizationMembership");
};
const handleDeleteUser = async () => {
const { userId } = popUp?.deleteUser?.data as { userId: string };
if (!userId) {
return;
}
await deleteUser(userId);
createNotification({
type: "success",
text: "Successfully deleted user"
});
handlePopUpClose("viewMembers");
handlePopUpClose("deleteUser");
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<OrganizationsPanelTable
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteOrganization.isOpen}
deleteKey="delete"
title={`Are you sure you want to delete organization ${
(popUp?.deleteOrganization?.data as { orgName: string })?.orgName || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteOrganization", isOpen)}
onDeleteApproved={handleDeleteOrganization}
/>
<DeleteActionModal
isOpen={popUp.deleteOrganizationMembership.isOpen}
deleteKey="delete"
title={`Are you sure you want to remove ${
(popUp?.deleteOrganizationMembership?.data as { username: string })?.username || ""
} from organization ${
(popUp?.deleteOrganizationMembership?.data as { orgName: string })?.orgName || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteOrganizationMembership", isOpen)}
onDeleteApproved={handleDeleteOrganizationMembership}
/>
<DeleteActionModal
isOpen={popUp.deleteUser.isOpen}
deleteKey="delete"
title={`Are you sure you want to delete user ${
(popUp?.deleteUser?.data as { username: string })?.username || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteUser", isOpen)}
onDeleteApproved={handleDeleteUser}
/>
</div>
);
};
@@ -0,0 +1 @@
export { OrganizationsTable } from "./OrganizationsTable";
@@ -0,0 +1,25 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { OrganizationResourcesPage } from "./OrganizationResourcesPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations"
)({
component: OrganizationResourcesPage,
beforeLoad: async () => {
return {
breadcrumbs: [
{
label: "Admin",
link: linkOptions({ to: "/admin" })
},
{
label: "Organizations",
link: linkOptions({
to: "/admin/resources/organizations"
})
}
]
};
}
});
@@ -1,424 +0,0 @@
import { Helmet } from "react-helmet";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { faAt } 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,
ContentLoader,
FormControl,
Input,
Select,
SelectClear,
SelectItem,
Switch,
Tab,
TabList,
TabPanel,
Tabs,
TextArea
} from "@app/components/v2";
import { useServerConfig, useUser } from "@app/context";
import {
useGetOrganizations,
useGetServerRootKmsEncryptionDetails,
useUpdateServerConfig
} from "@app/hooks/api";
import { IdentityPanel } from "@app/pages/admin/OverviewPage/components/IdentityPanel";
import { AuthPanel } from "./components/AuthPanel";
import { CachingPanel } from "./components/CachingPanel";
import { EncryptionPanel } from "./components/EncryptionPanel";
import { IntegrationPanel } from "./components/IntegrationPanel";
import { UserPanel } from "./components/UserPanel";
enum TabSections {
Settings = "settings",
Encryption = "encryption",
Auth = "auth",
Integrations = "integrations",
Users = "users",
Identities = "identities",
Kmip = "kmip",
Caching = "caching"
}
enum SignUpModes {
Disabled = "disabled",
Anyone = "anyone"
}
const formSchema = z.object({
signUpMode: z.nativeEnum(SignUpModes),
allowedSignUpDomain: z.string().optional().nullable(),
trustSamlEmails: z.boolean(),
trustLdapEmails: z.boolean(),
trustOidcEmails: z.boolean(),
defaultAuthOrgId: z.string(),
authConsentContent: z.string().optional().default(""),
pageFrameContent: z.string().optional().default("")
});
type TDashboardForm = z.infer<typeof formSchema>;
export const OverviewPage = () => {
const { t } = useTranslation();
const data = useServerConfig();
const { data: serverRootKmsDetails } = useGetServerRootKmsEncryptionDetails();
const { config } = data;
const {
control,
handleSubmit,
watch,
formState: { isSubmitting, isDirty }
} = useForm<TDashboardForm>({
resolver: zodResolver(formSchema),
values: {
// eslint-disable-next-line
signUpMode: config.allowSignUp ? SignUpModes.Anyone : SignUpModes.Disabled,
allowedSignUpDomain: config.allowedSignUpDomain,
trustSamlEmails: config.trustSamlEmails,
trustLdapEmails: config.trustLdapEmails,
trustOidcEmails: config.trustOidcEmails,
defaultAuthOrgId: config.defaultAuthOrgId ?? "",
authConsentContent: config.authConsentContent ?? "",
pageFrameContent: config.pageFrameContent ?? ""
}
});
const signUpMode = watch("signUpMode");
const defaultAuthOrgId = watch("defaultAuthOrgId");
const { user } = useUser();
const { mutateAsync: updateServerConfig } = useUpdateServerConfig();
const organizations = useGetOrganizations();
const isNotAllowed = !user?.superAdmin;
const onFormSubmit = async (formData: TDashboardForm) => {
try {
const {
allowedSignUpDomain,
trustSamlEmails,
trustLdapEmails,
trustOidcEmails,
authConsentContent,
pageFrameContent
} = formData;
await updateServerConfig({
defaultAuthOrgId: defaultAuthOrgId || null,
allowSignUp: signUpMode !== SignUpModes.Disabled,
allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null,
trustSamlEmails,
trustLdapEmails,
trustOidcEmails,
authConsentContent,
pageFrameContent
});
createNotification({
text: "Successfully changed sign up setting.",
type: "success"
});
} catch (e) {
console.error(e);
createNotification({
type: "error",
text: "Failed to update sign up setting."
});
}
};
return (
<>
<Helmet>
<title>{t("common.head-title", { title: t("admin.dashboard") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("admin.dashboard.og-title") ?? ""} />
<meta name="og:description" content={t("admin.dashboard.og-description") ?? ""} />
</Helmet>
<div className="h-full">
<div className="container mx-auto max-w-7xl px-4 pb-12 text-white dark:[color-scheme:dark]">
<div className="mx-auto mb-6 w-full max-w-7xl pt-6">
<div className="mb-8 flex flex-col items-start justify-between text-xl">
<h1 className="text-3xl font-semibold">Server Admin Console</h1>
<p className="text-base text-bunker-300">
Manage your instance level configurations.
</p>
</div>
</div>
{isNotAllowed ? (
<ContentLoader text={isNotAllowed ? "Redirecting to org page..." : undefined} />
) : (
<div>
<Tabs defaultValue={TabSections.Settings}>
<TabList>
<div className="flex w-full flex-row border-b border-mineshaft-600">
<Tab value={TabSections.Settings}>General</Tab>
<Tab value={TabSections.Encryption}>Encryption</Tab>
<Tab value={TabSections.Auth}>Authentication</Tab>
<Tab value={TabSections.Integrations}>Integrations</Tab>
<Tab value={TabSections.Users}>User Identities</Tab>
<Tab value={TabSections.Identities}>Machine Identities</Tab>
<Tab value={TabSections.Caching}>Caching</Tab>
</div>
</TabList>
<TabPanel value={TabSections.Settings}>
<form
className="mb-6 space-y-8 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
onSubmit={handleSubmit(onFormSubmit)}
>
<div className="flex flex-col justify-start">
<div className="mb-2 text-xl font-semibold text-mineshaft-100">
Allow user signups
</div>
<div className="mb-4 max-w-sm text-sm text-mineshaft-400">
Select if you want users to be able to signup freely into your Infisical
instance.
</div>
<Controller
control={control}
name="signUpMode"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="max-w-sm"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
className="w-full bg-mineshaft-700"
dropdownContainerClassName="bg-mineshaft-800"
defaultValue={field.value}
onValueChange={(e) => onChange(e)}
{...field}
>
<SelectItem value={SignUpModes.Disabled}>Disabled</SelectItem>
<SelectItem value={SignUpModes.Anyone}>Anyone</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
{signUpMode === "anyone" && (
<div className="flex flex-col justify-start">
<div className="mb-4 text-xl font-semibold text-mineshaft-100">
Restrict signup by email domain(s)
</div>
<Controller
control={control}
defaultValue=""
name="allowedSignUpDomain"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Leave blank to allow any email domains"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
placeholder="gmail.com, aws.com, redhat.com"
leftIcon={<FontAwesomeIcon icon={faAt} />}
/>
</FormControl>
)}
/>
</div>
)}
<div className="flex flex-col justify-start">
<div className="mb-2 text-xl font-semibold text-mineshaft-100">
Default organization
</div>
<div className="mb-4 max-w-sm text-sm text-mineshaft-400">
Select the default organization you want to set for SAML/LDAP/OIDC/Github
logins. When selected, user logins will be automatically scoped to the
selected organization.
</div>
<Controller
control={control}
name="defaultAuthOrgId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="max-w-sm"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
placeholder="Allow all organizations"
className="w-full bg-mineshaft-700"
dropdownContainerClassName="bg-mineshaft-800"
defaultValue={field.value ?? " "}
onValueChange={(e) => onChange(e)}
{...field}
>
<SelectClear
selectValue={defaultAuthOrgId}
onClear={() => {
onChange("");
}}
>
Allow all organizations
</SelectClear>
{organizations.data?.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div className="flex flex-col justify-start">
<div className="mb-2 text-xl font-semibold text-mineshaft-100">
Trust emails
</div>
<div className="mb-4 max-w-sm text-sm text-mineshaft-400">
Select if you want Infisical to trust external emails from SAML/LDAP/OIDC
identity providers. If set to false, then Infisical will prompt
SAML/LDAP/OIDC provisioned users to verify their email upon their first
login.
</div>
<Controller
control={control}
name="trustSamlEmails"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="trust-saml-emails"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Trust SAML emails</p>
</Switch>
</FormControl>
);
}}
/>
<Controller
control={control}
name="trustLdapEmails"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="trust-ldap-emails"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Trust LDAP emails</p>
</Switch>
</FormControl>
);
}}
/>
<Controller
control={control}
name="trustOidcEmails"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="trust-oidc-emails"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Trust OIDC emails</p>
</Switch>
</FormControl>
);
}}
/>
</div>
<div className="flex flex-col justify-start">
<div className="mb-2 text-xl font-semibold text-mineshaft-100">Notices</div>
<div className="mb-4 max-w-lg text-sm text-mineshaft-400">
Configure system-wide notification banners and security messages. These
settings control the text displayed to users during authentication and
throughout their session
</div>
<Controller
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Auth Consent Content"
tooltipText="Formatting supported: HTML, Markdown, Plain text"
>
<TextArea
placeholder="**Auth Consent Message**"
{...field}
rows={3}
className="thin-scrollbar h-48 max-w-lg !resize-none bg-mineshaft-800"
/>
</FormControl>
)}
control={control}
name="authConsentContent"
/>
<Controller
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Page Frame Content"
tooltipText="Formatting supported: HTML, Markdown, Plain text"
>
<TextArea
placeholder='<div style="background-color: red">TOP SECRET</div>'
{...field}
rows={3}
className="thin-scrollbar h-48 max-w-lg !resize-none bg-mineshaft-800"
/>
</FormControl>
)}
control={control}
name="pageFrameContent"
/>
</div>
<Button
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
Save
</Button>
</form>
</TabPanel>
<TabPanel value={TabSections.Encryption}>
<EncryptionPanel rootKmsDetails={serverRootKmsDetails} />
</TabPanel>
<TabPanel value={TabSections.Auth}>
<AuthPanel />
</TabPanel>
<TabPanel value={TabSections.Integrations}>
<IntegrationPanel />
</TabPanel>
<TabPanel value={TabSections.Users}>
<UserPanel />
</TabPanel>
<TabPanel value={TabSections.Identities}>
<IdentityPanel />
</TabPanel>
<TabPanel value={TabSections.Caching}>
<CachingPanel />
</TabPanel>
</Tabs>
</div>
)}
</div>
</div>
</>
);
};
@@ -1,7 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { OverviewPage } from "./OverviewPage";
export const Route = createFileRoute("/_authenticate/_inject-org-details/admin/_admin-layout/")({
component: OverviewPage
});
@@ -0,0 +1,27 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { UserIdentitiesTable } from "./components";
export const UserIdentitiesResourcesPage = () => {
const { t } = useTranslation();
return (
<div className="h-full bg-bunker-800">
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader
title="User Identities"
description="Manage all user identities within your Infisical instance."
/>
<UserIdentitiesTable />
</div>
</div>
</div>
);
};
@@ -213,7 +213,7 @@ const UserPanelTable = ({
<Button
className="mt-4 py-3 text-sm"
isFullWidth
variant="star"
variant="outline_bg"
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()}
@@ -226,7 +226,7 @@ const UserPanelTable = ({
);
};
export const UserPanel = () => {
export const UserIdentitiesTable = () => {
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"removeUser",
"upgradePlan",
@@ -297,9 +297,6 @@ export const UserPanel = () => {
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4">
<p className="text-xl font-semibold text-mineshaft-100">Users</p>
</div>
<UserPanelTable handlePopUpOpen={handlePopUpOpen} />
<DeleteActionModal
isOpen={popUp.removeUser.isOpen}
@@ -0,0 +1 @@
export { UserIdentitiesTable } from "./UserIdentitiesTable";
@@ -0,0 +1,25 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { UserIdentitiesResourcesPage } from "./UserIdentitiesResourcesPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities"
)({
component: UserIdentitiesResourcesPage,
beforeLoad: async () => {
return {
breadcrumbs: [
{
label: "Admin",
link: linkOptions({ to: "/admin" })
},
{
label: "User Identities",
link: linkOptions({
to: "/admin/resources/user-identities"
})
}
]
};
}
});
@@ -18,7 +18,8 @@ import { useToggle } from "@app/hooks";
import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { fetchMyPrivateKey } from "@app/hooks/api/users/queries";
import { fetchMyPrivateKey, fetchUserDuplicateAccounts } from "@app/hooks/api/users/queries";
import { EmailDuplicationConfirmation } from "@app/pages/auth/SelectOrgPage/EmailDuplicationConfirmation";
import { navigateUserToOrg, useNavigateToSelectOrganization } from "../../Login.utils";
@@ -40,6 +41,7 @@ export const PasswordStep = ({
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const navigate = useNavigate();
const [removeDuplicateLater, setRemoveDuplicateLater] = useState(true);
const { mutateAsync: selectOrganization } = useSelectOrganization();
const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
@@ -109,6 +111,13 @@ export const PasswordStep = ({
return;
}
const userDuplicateAccount = await fetchUserDuplicateAccounts();
const hasDuplicate = userDuplicateAccount?.length > 1;
if (hasDuplicate) {
setRemoveDuplicateLater(false);
return;
}
await navigateUserToOrg(navigate, organizationId);
};
@@ -306,6 +315,18 @@ export const PasswordStep = ({
);
}
if (!removeDuplicateLater) {
return (
<EmailDuplicationConfirmation
onRemoveDuplicateLater={() =>
navigateUserToOrg(navigate, organizationId).catch(() =>
createNotification({ text: "Failed to navigate user", type: "error" })
)
}
/>
);
}
if (hasExchangedPrivateKey) {
return (
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
@@ -0,0 +1,164 @@
import { useCallback } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate } from "@tanstack/react-router";
import { format } from "date-fns";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal, Tooltip } from "@app/components/v2";
import { usePopUp } from "@app/hooks";
import {
useGetMyDuplicateAccount,
useLogoutUser,
useRemoveMyDuplicateAccounts
} from "@app/hooks/api";
type Props = {
onRemoveDuplicateLater: () => void;
};
export const EmailDuplicationConfirmation = ({ onRemoveDuplicateLater }: Props) => {
const duplicateAccounts = useGetMyDuplicateAccount();
const removeDuplicateEmails = useRemoveMyDuplicateAccounts();
const { t } = useTranslation();
const navigate = useNavigate();
const logout = useLogoutUser(true);
const { popUp, handlePopUpToggle } = usePopUp(["removeDuplicateConfirm"] as const);
const handleLogout = useCallback(async () => {
try {
console.log("Logging out...");
await logout.mutateAsync();
navigate({ to: "/login" });
} catch (error) {
console.error(error);
}
}, [logout, navigate]);
return (
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Helmet>
<title>{t("common.head-title", { title: t("login.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("login.og-title") ?? ""} />
<meta name="og:description" content={t("login.og-description") ?? ""} />
</Helmet>
<div className="mx-auto mt-20 w-fit max-w-2xl rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg">
<Link to="/">
<div className="mb-4 flex justify-center">
<img
src="/images/gradientLogo.svg"
style={{
height: "90px",
width: "120px"
}}
alt="Infisical logo"
/>
</div>
</Link>
<form className="mx-auto flex w-full flex-col items-center justify-center">
<div className="mb-6">
<h1 className="mb-2 bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-2xl font-medium text-transparent">
Multiple Accounts Detected
</h1>
<p className="text-md mb-4 text-center text-white">
<span className="text-slate-300">You&apos;re currently logged in as</span>{" "}
<b>{duplicateAccounts?.data?.myAccount?.username}</b>.
</p>
<div className="mb-4 mt-4 flex flex-col rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5">
<p className="mb-2 mt-1 text-sm text-bunker-300">
We&apos;ve detected multiple accounts using variations of the same email address.
</p>
</div>
</div>
<div className="mb-4 w-full border-b border-mineshaft-400 pb-1 text-sm text-mineshaft-200">
Your other accounts
</div>
<div className="thin-scrollbar flex h-full max-h-60 w-full flex-col items-stretch gap-2 overflow-auto rounded-md">
{duplicateAccounts?.data?.duplicateAccounts?.map((el) => {
const lastSession = el.devices?.at(-1);
return (
<div
key={el.id}
className="flex items-center gap-8 rounded-md bg-mineshaft-700 px-4 py-3 text-gray-200"
>
<div className="group flex flex-grow flex-col">
<div className="truncate text-sm transition-colors">{el.username}</div>
<div className="mt-2 text-xs">
Last logged in at {format(new Date(el.updatedAt), "Pp")}
</div>
<div className="mt-2 text-xs">
Organizations: {el?.organizations?.map((i) => i.slug)?.join(",")}
</div>
</div>
<div>
<Tooltip
className="max-w-lg"
content={
<div className="flex flex-col space-y-1 text-sm">
<div>IP: {lastSession?.ip || "-"}</div>
<div>User Agent: {lastSession?.userAgent || "-"}</div>
</div>
}
>
<FontAwesomeIcon icon={faInfoCircle} />
</Tooltip>
</div>
</div>
);
})}
</div>
<div className="mt-4 flex w-full flex-col">
<div className="flex gap-6">
<Button
className="flex-1 flex-grow"
isLoading={removeDuplicateEmails.isPending}
onClick={() => handlePopUpToggle("removeDuplicateConfirm", true)}
>
Delete all other accounts
</Button>
<Button
variant="outline_bg"
onClick={() => onRemoveDuplicateLater()}
className="flex-1 flex-grow"
>
Remind me later
</Button>
</div>
<Button
isLoading={logout.isPending}
variant="plain"
colorSchema="secondary"
className="mt-4"
onClick={handleLogout}
>
Change Account
</Button>
</div>
</form>
</div>
<div className="pb-28" />
<DeleteActionModal
isOpen={popUp.removeDuplicateConfirm.isOpen}
subTitle={`You’re currently logged in as ${duplicateAccounts?.data?.myAccount?.username}. Once you confirm, your other duplicate accounts will be permanently removed. Please make sure none of those accounts contain any production secrets, as this action cannot be undone.`}
title="Confirmation Required"
onChange={(isOpen) => handlePopUpToggle("removeDuplicateConfirm", isOpen)}
deleteKey="remove"
buttonText="Confirm"
onDeleteApproved={() =>
removeDuplicateEmails.mutateAsync(undefined, {
onSuccess: () => {
createNotification({
type: "success",
text: "Removed duplicate accounts"
});
onRemoveDuplicateLater();
}
})
}
/>
</div>
);
};
@@ -1,33 +1,10 @@
import { useCallback, useEffect, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate } from "@tanstack/react-router";
import axios from "axios";
import { addSeconds, formatISO } from "date-fns";
import { jwtDecode } from "jwt-decode";
import { useState } from "react";
import { Mfa } from "@app/components/auth/Mfa";
import { createNotification } from "@app/components/notifications";
import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Spinner } from "@app/components/v2";
import { SessionStorageKeys } from "@app/const";
import { OrgMembershipRole } from "@app/helpers/roles";
import { useToggle } from "@app/hooks";
import {
useGetOrganizations,
useGetUser,
useLogoutUser,
useSelectOrganization
} from "@app/hooks/api";
import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types";
import { getAuthToken, isLoggedIn } from "@app/hooks/api/reactQuery";
import { Organization } from "@app/hooks/api/types";
import { AuthMethod } from "@app/hooks/api/users/types";
import { Spinner } from "@app/components/v2";
import { useGetMyDuplicateAccount } from "@app/hooks/api";
import { navigateUserToOrg } from "../LoginPage/Login.utils";
import { EmailDuplicationConfirmation } from "./EmailDuplicationConfirmation";
import { SelectOrganizationSection } from "./SelectOrgSection";
const LoadingScreen = () => {
return (
@@ -39,253 +16,18 @@ const LoadingScreen = () => {
};
export const SelectOrganizationPage = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const duplicateAccounts = useGetMyDuplicateAccount();
const [removeDuplicateLater, setRemoveDuplicateLater] = useState(false);
const organizations = useGetOrganizations();
const selectOrg = useSelectOrganization();
const { data: user, isPending: userLoading } = useGetUser();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const queryParams = new URLSearchParams(window.location.search);
const orgId = queryParams.get("org_id");
const callbackPort = queryParams.get("callback_port");
const isAdminLogin = queryParams.get("is_admin_login") === "true";
const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId);
const logout = useLogoutUser(true);
const handleLogout = useCallback(async () => {
try {
console.log("Logging out...");
await logout.mutateAsync();
navigate({ to: "/login" });
} catch (error) {
console.error(error);
}
}, [logout, navigate]);
const handleSelectOrganization = useCallback(
async (organization: Organization) => {
const canBypassOrgAuth =
organization.bypassOrgAuthEnabled &&
organization.userRole === OrgMembershipRole.Admin &&
isAdminLogin;
if (organization.authEnforced && !canBypassOrgAuth) {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
await logout.mutateAsync();
let url = "";
if (organization.orgAuthMethod === AuthMethod.OIDC) {
url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${
callbackPort ? `&callbackPort=${callbackPort}` : ""
}`;
} else {
url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`;
if (callbackPort) {
url += `?callback_port=${callbackPort}`;
}
}
window.location.href = url;
return;
}
const { token, isMfaEnabled, mfaMethod } = await selectOrg
.mutateAsync({
organizationId: organization.id,
userAgent: callbackPort ? UserAgentType.CLI : undefined
})
.finally(() => setIsInitialOrgCheckLoading(false));
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => () => handleSelectOrganization(organization));
return;
}
if (callbackPort) {
const privateKey = localStorage.getItem("PRIVATE_KEY");
let error: string | null = null;
if (!privateKey) error = "Private key not found";
if (!user?.email) error = "User email not found";
if (!token) error = "No token found";
if (error) {
createNotification({
text: error,
type: "error"
});
return;
}
const payload = {
JTWToken: token,
email: user?.email,
privateKey
} as IsCliLoginSuccessful["loginResponse"];
// send request to server endpoint
const instance = axios.create();
await instance.post(`http://127.0.0.1:${callbackPort}/`, payload).catch(() => {
// if error happens to communicate we set the token with an expiry in sessino storage
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
sessionStorage.setItem(
SessionStorageKeys.CLI_TERMINAL_TOKEN,
JSON.stringify({
expiry: formatISO(addSeconds(new Date(), 30)),
data: window.btoa(JSON.stringify(payload))
})
);
});
navigate({ to: "/cli-redirect" });
// cli page
} else {
navigateUserToOrg(navigate, organization.id);
}
},
[selectOrg]
);
const handleCliRedirect = useCallback(() => {
const authToken = getAuthToken();
if (authToken && !callbackPort) {
const decodedJwt = jwtDecode(authToken) as any;
if (decodedJwt?.organizationId) {
navigateUserToOrg(navigate, decodedJwt.organizationId);
}
}
if (!isLoggedIn()) {
navigate({ to: "/login" });
}
}, []);
useEffect(() => {
if (callbackPort) {
handleCliRedirect();
}
}, [navigate]);
useEffect(() => {
if (organizations.isPending || !organizations.data) return;
// Case: User has no organizations.
// This can happen if the user was previously a member, but the organization was deleted or the user was removed.
if (organizations.data.length === 0) {
navigate({ to: "/organization/none" });
} else if (organizations.data.length === 1) {
if (callbackPort) {
handleCliRedirect();
setIsInitialOrgCheckLoading(false);
} else {
handleSelectOrganization(organizations.data[0]);
}
} else {
setIsInitialOrgCheckLoading(false);
}
}, [organizations.isPending, organizations.data]);
useEffect(() => {
if (defaultSelectedOrg) {
handleSelectOrganization(defaultSelectedOrg);
}
}, [defaultSelectedOrg]);
if (
userLoading ||
!user ||
((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa)
) {
if (duplicateAccounts.isPending) {
return <LoadingScreen />;
}
return (
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Helmet>
<title>{t("common.head-title", { title: t("login.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("login.og-title") ?? ""} />
<meta name="og:description" content={t("login.og-description") ?? ""} />
</Helmet>
{shouldShowMfa ? (
<Mfa
email={user.email as string}
successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
/>
) : (
<div className="mx-auto mt-20 w-fit rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg">
<Link to="/">
<div className="mb-4 flex justify-center">
<img
src="/images/gradientLogo.svg"
style={{
height: "90px",
width: "120px"
}}
alt="Infisical logo"
/>
</div>
</Link>
<form className="mx-auto flex w-full flex-col items-center justify-center">
<div className="mb-8 space-y-2">
<h1 className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-2xl font-medium text-transparent">
Choose your organization
</h1>
if (duplicateAccounts.data?.duplicateAccounts?.length && !removeDuplicateLater) {
return (
<EmailDuplicationConfirmation onRemoveDuplicateLater={() => setRemoveDuplicateLater(true)} />
);
}
<div className="space-y-1">
<p className="text-md text-center text-gray-500">
You&lsquo;re currently logged in as <strong>{user.username}</strong>
</p>
<p className="text-md text-center text-gray-500">
Not you?{" "}
<Button variant="link" onClick={handleLogout} className="font-semibold">
Change account
</Button>
</p>
</div>
</div>
<div className="mt-2 w-1/4 min-w-[21.2rem] space-y-4 rounded-md text-center md:min-w-[25.1rem] lg:w-1/4">
{organizations.isPending ? (
<Spinner />
) : (
organizations.data?.map((org) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
onClick={() => handleSelectOrganization(org)}
key={org.id}
className="group flex cursor-pointer items-center justify-between rounded-md bg-mineshaft-700 px-4 py-3 capitalize text-gray-200 shadow-md transition-colors hover:bg-mineshaft-600"
>
<p className="truncate transition-colors">{org.name}</p>
<FontAwesomeIcon
icon={faArrowRight}
className="text-gray-400 transition-all group-hover:translate-x-2 group-hover:text-primary-500"
/>
</div>
))
)}
</div>
</form>
</div>
)}
<div className="pb-28" />
</div>
);
return <SelectOrganizationSection />;
};
@@ -0,0 +1,304 @@
import { useCallback, useEffect, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate } from "@tanstack/react-router";
import axios from "axios";
import { addSeconds, formatISO } from "date-fns";
import { jwtDecode } from "jwt-decode";
import { Mfa } from "@app/components/auth/Mfa";
import { createNotification } from "@app/components/notifications";
import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Spinner } from "@app/components/v2";
import { SessionStorageKeys } from "@app/const";
import { OrgMembershipRole } from "@app/helpers/roles";
import { useToggle } from "@app/hooks";
import {
useGetOrganizations,
useGetUser,
useLogoutUser,
useSelectOrganization
} from "@app/hooks/api";
import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types";
import { getAuthToken, isLoggedIn } from "@app/hooks/api/reactQuery";
import { Organization } from "@app/hooks/api/types";
import { AuthMethod } from "@app/hooks/api/users/types";
import { navigateUserToOrg } from "../LoginPage/Login.utils";
const LoadingScreen = () => {
return (
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Spinner />
<p className="text-white opacity-80">Loading, please wait</p>
</div>
);
};
export const SelectOrganizationSection = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const organizations = useGetOrganizations();
const selectOrg = useSelectOrganization();
const { data: user, isPending: userLoading } = useGetUser();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const queryParams = new URLSearchParams(window.location.search);
const orgId = queryParams.get("org_id");
const callbackPort = queryParams.get("callback_port");
const isAdminLogin = queryParams.get("is_admin_login") === "true";
const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId);
const logout = useLogoutUser(true);
const handleLogout = useCallback(async () => {
try {
console.log("Logging out...");
await logout.mutateAsync();
navigate({ to: "/login" });
} catch (error) {
console.error(error);
}
}, [logout, navigate]);
const handleSelectOrganization = useCallback(
async (organization: Organization) => {
const isUserOrgAdmin = organization.userRole === OrgMembershipRole.Admin;
const canBypassOrgAuth = organization.bypassOrgAuthEnabled && isUserOrgAdmin && isAdminLogin;
if (isAdminLogin) {
if (!organization.bypassOrgAuthEnabled) {
createNotification({
text: "This organization does not have bypass org auth enabled",
type: "error"
});
return;
}
if (!isUserOrgAdmin) {
createNotification({
text: "Only organization admins can bypass org auth",
type: "error"
});
return;
}
}
if (organization.authEnforced && !canBypassOrgAuth) {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
await logout.mutateAsync();
let url = "";
if (organization.orgAuthMethod === AuthMethod.OIDC) {
url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${
callbackPort ? `&callbackPort=${callbackPort}` : ""
}`;
} else {
url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`;
if (callbackPort) {
url += `?callback_port=${callbackPort}`;
}
}
window.location.href = url;
return;
}
const { token, isMfaEnabled, mfaMethod } = await selectOrg
.mutateAsync({
organizationId: organization.id,
userAgent: callbackPort ? UserAgentType.CLI : undefined
})
.finally(() => setIsInitialOrgCheckLoading(false));
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => () => handleSelectOrganization(organization));
return;
}
if (callbackPort) {
const privateKey = localStorage.getItem("PRIVATE_KEY");
let error: string | null = null;
if (!privateKey) error = "Private key not found";
if (!user?.email) error = "User email not found";
if (!token) error = "No token found";
if (error) {
createNotification({
text: error,
type: "error"
});
return;
}
const payload = {
JTWToken: token,
email: user?.email,
privateKey
} as IsCliLoginSuccessful["loginResponse"];
// send request to server endpoint
const instance = axios.create();
await instance.post(`http://127.0.0.1:${callbackPort}/`, payload).catch(() => {
// if error happens to communicate we set the token with an expiry in sessino storage
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
sessionStorage.setItem(
SessionStorageKeys.CLI_TERMINAL_TOKEN,
JSON.stringify({
expiry: formatISO(addSeconds(new Date(), 30)),
data: window.btoa(JSON.stringify(payload))
})
);
});
navigate({ to: "/cli-redirect" });
// cli page
} else {
navigateUserToOrg(navigate, organization.id);
}
},
[selectOrg]
);
const handleCliRedirect = useCallback(() => {
const authToken = getAuthToken();
if (authToken && !callbackPort) {
const decodedJwt = jwtDecode(authToken) as any;
if (decodedJwt?.organizationId) {
navigateUserToOrg(navigate, decodedJwt.organizationId);
}
}
if (!isLoggedIn()) {
navigate({ to: "/login" });
}
}, []);
useEffect(() => {
if (callbackPort) {
handleCliRedirect();
}
}, [navigate]);
useEffect(() => {
if (organizations.isPending || !organizations.data) return;
// Case: User has no organizations.
// This can happen if the user was previously a member, but the organization was deleted or the user was removed.
if (organizations.data.length === 0) {
navigate({ to: "/organization/none" });
} else if (organizations.data.length === 1) {
if (callbackPort) {
handleCliRedirect();
setIsInitialOrgCheckLoading(false);
} else {
handleSelectOrganization(organizations.data[0]);
}
} else {
setIsInitialOrgCheckLoading(false);
}
}, [organizations.isPending, organizations.data]);
useEffect(() => {
if (defaultSelectedOrg) {
handleSelectOrganization(defaultSelectedOrg);
}
}, [defaultSelectedOrg]);
if (
userLoading ||
!user ||
((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa)
) {
return <LoadingScreen />;
}
return (
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Helmet>
<title>{t("common.head-title", { title: t("login.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("login.og-title") ?? ""} />
<meta name="og:description" content={t("login.og-description") ?? ""} />
</Helmet>
{shouldShowMfa ? (
<Mfa
email={user.email as string}
successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
/>
) : (
<div className="mx-auto mt-20 w-fit rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg">
<Link to="/">
<div className="mb-4 flex justify-center">
<img
src="/images/gradientLogo.svg"
style={{
height: "90px",
width: "120px"
}}
alt="Infisical logo"
/>
</div>
</Link>
<form className="mx-auto flex w-full flex-col items-center justify-center">
<div className="mb-8 space-y-2">
<h1 className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-2xl font-medium text-transparent">
Choose your organization
</h1>
<div className="space-y-1">
<p className="text-md text-center text-gray-500">
You&lsquo;re currently logged in as <strong>{user.username}</strong>
</p>
<p className="text-md text-center text-gray-500">
Not you?{" "}
<Button variant="link" onClick={handleLogout} className="font-semibold">
Change account
</Button>
</p>
</div>
</div>
<div className="mt-2 w-1/4 min-w-[21.2rem] space-y-4 rounded-md text-center md:min-w-[25.1rem] lg:w-1/4">
{organizations.isPending ? (
<Spinner />
) : (
organizations.data?.map((org) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
onClick={() => handleSelectOrganization(org)}
key={org.id}
className="group flex cursor-pointer items-center justify-between rounded-md bg-mineshaft-700 px-4 py-3 capitalize text-gray-200 shadow-md transition-colors hover:bg-mineshaft-600"
>
<p className="truncate transition-colors">{org.name}</p>
<FontAwesomeIcon
icon={faArrowRight}
className="text-gray-400 transition-all group-hover:translate-x-2 group-hover:text-primary-500"
/>
</div>
))
)}
</div>
</form>
</div>
)}
<div className="pb-28" />
</div>
);
};
@@ -16,13 +16,13 @@ import {
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useDeleteCa, useGetCaById } from "@app/hooks/api";
import { CaType, useDeleteCa, useGetCa } from "@app/hooks/api";
import { TInternalCertificateAuthority } from "@app/hooks/api/ca/types";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { usePopUp } from "@app/hooks/usePopUp";
import { CaInstallCertModal } from "../CertificateAuthoritiesPage/components/CaInstallCertModal";
import { CaModal } from "../CertificateAuthoritiesPage/components/CaModal";
import { CertificateTemplatesSection } from "../CertificatesPage/components/CertificateTemplatesSection";
import {
CaCertificatesSection,
CaCrlsSection,
@@ -31,14 +31,18 @@ import {
} from "./components";
const Page = () => {
const { currentWorkspace } = useWorkspace();
const navigate = useNavigate();
const params = useParams({
from: ROUTE_PATHS.CertManager.CertAuthDetailsByIDPage.id
});
const caId = params.caId as string;
const { data } = useGetCaById(caId);
const { caName } = params as { caName: string };
const { data } = useGetCa({
caName,
projectId: currentWorkspace?.id || "",
type: CaType.INTERNAL
}) as { data: TInternalCertificateAuthority };
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const { mutateAsync: deleteCa } = useDeleteCa();
@@ -50,11 +54,15 @@ const Page = () => {
"renewCa"
] as const);
const onRemoveCaSubmit = async (caIdToDelete: string) => {
const onRemoveCaSubmit = async () => {
try {
if (!currentWorkspace?.slug) return;
await deleteCa({ caId: caIdToDelete, projectSlug: currentWorkspace.slug });
await deleteCa({
caName,
projectId: currentWorkspace.id,
type: CaType.INTERNAL
});
createNotification({
text: "Successfully deleted CA",
@@ -63,7 +71,7 @@ const Page = () => {
handlePopUpClose("deleteCa");
navigate({
to: `/${ProjectType.CertificateManager}/$projectId/certificates` as const,
to: `/${ProjectType.CertificateManager}/$projectId/certificate-authorities` as const,
params: {
projectId
}
@@ -80,7 +88,7 @@ const Page = () => {
<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">
<PageHeader title={data.friendlyName}>
<PageHeader title={data.name}>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
@@ -101,12 +109,7 @@ const Page = () => {
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() =>
handlePopUpOpen("deleteCa", {
caId: data.id,
dn: data.dn
})
}
onClick={() => handlePopUpOpen("deleteCa")}
disabled={!isAllowed}
>
Delete CA
@@ -118,12 +121,11 @@ const Page = () => {
</PageHeader>
<div className="flex">
<div className="mr-4 w-96">
<CaDetailsSection caId={caId} handlePopUpOpen={handlePopUpOpen} />
<CaDetailsSection caName={data.name} handlePopUpOpen={handlePopUpOpen} />
</div>
<div className="w-full">
<CaCertificatesSection caId={caId} />
<CertificateTemplatesSection caId={caId} />
<CaCrlsSection caId={caId} />
<CaCertificatesSection caId={data.id} />
<CaCrlsSection caId={data.id} />
</div>
</div>
</div>
@@ -139,7 +141,7 @@ const Page = () => {
subTitle="This action will delete other CAs and certificates below it in your CA hierarchy."
onChange={(isOpen) => handlePopUpToggle("deleteCa", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => onRemoveCaSubmit((popUp?.deleteCa?.data as { caId: string })?.caId)}
onDeleteApproved={onRemoveCaSubmit}
/>
</div>
);
@@ -4,22 +4,24 @@ import { format } from "date-fns";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, IconButton, Tooltip } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useTimedReset } from "@app/hooks";
import { CaStatus, CaType, useGetCaById } from "@app/hooks/api";
import { CaStatus, CaType, InternalCaType, useGetCa } from "@app/hooks/api";
import { caStatusToNameMap, caTypeToNameMap } from "@app/hooks/api/ca/constants";
import { TInternalCertificateAuthority } from "@app/hooks/api/ca/types";
import { certKeyAlgorithmToNameMap } from "@app/hooks/api/certificates/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
caId: string;
caName: string;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["ca", "renewCa", "installCaCert"]>,
data?: object
) => void;
};
export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
export const CaDetailsSection = ({ caName, handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
@@ -27,7 +29,13 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
initialState: "Copy ID to clipboard"
});
const { data: ca } = useGetCaById(caId);
const { data } = useGetCa({
caName,
projectId: currentWorkspace.id,
type: CaType.INTERNAL
});
const ca = data as TInternalCertificateAuthority;
return ca ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
@@ -45,7 +53,7 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("ca", {
caId: ca.id
name: ca.name
});
}}
>
@@ -59,7 +67,7 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">CA Type</p>
<p className="text-sm text-mineshaft-300">{caTypeToNameMap[ca.type]}</p>
<p className="text-sm text-mineshaft-300">{caTypeToNameMap[ca.configuration.type]}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">CA ID</p>
@@ -82,36 +90,39 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
</div>
</div>
</div>
{ca.type === CaType.INTERMEDIATE && ca.status !== CaStatus.PENDING_CERTIFICATE && (
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Parent CA ID</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">
{ca.parentCaId ? ca.parentCaId : "N/A - External Parent CA"}
</p>
{ca.parentCaId && (
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextParentId}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText(ca.parentCaId as string);
setCopyTextParentId("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingParentId ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
)}
{ca.configuration.type === InternalCaType.INTERMEDIATE &&
ca.status !== CaStatus.PENDING_CERTIFICATE && (
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Parent CA ID</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">
{ca.configuration.parentCaId
? ca.configuration.parentCaId
: "N/A - External Parent CA"}
</p>
{ca.configuration.parentCaId && (
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextParentId}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText(ca.configuration.parentCaId as string);
setCopyTextParentId("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingParentId ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
)}
</div>
</div>
</div>
)}
)}
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Friendly Name</p>
<p className="text-sm text-mineshaft-300">{ca.friendlyName}</p>
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">{ca.name}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Status</p>
@@ -119,29 +130,33 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Key Algorithm</p>
<p className="text-sm text-mineshaft-300">{certKeyAlgorithmToNameMap[ca.keyAlgorithm]}</p>
<p className="text-sm text-mineshaft-300">
{certKeyAlgorithmToNameMap[ca.configuration.keyAlgorithm]}
</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Max Path Length</p>
<p className="text-sm text-mineshaft-300">{ca.maxPathLength ?? "-"}</p>
<p className="text-sm text-mineshaft-300">{ca.configuration.maxPathLength ?? "-"}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Not Before</p>
<p className="text-sm text-mineshaft-300">
{ca.notBefore ? format(new Date(ca.notBefore), "yyyy-MM-dd") : "-"}
{ca.configuration.notBefore
? format(new Date(ca.configuration.notBefore), "yyyy-MM-dd")
: "-"}
</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Not After</p>
<p className="text-sm text-mineshaft-300">
{ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "-"}
{ca.configuration.notAfter
? format(new Date(ca.configuration.notAfter), "yyyy-MM-dd")
: "-"}
</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Template Issuance Required</p>
<p className="text-sm text-mineshaft-300">
{ca.requireTemplateForIssuance ? "True" : "False"}
</p>
<p className="text-sm font-semibold text-mineshaft-300">Enable Direct Issuance</p>
<p className="text-sm text-mineshaft-300">{ca.enableDirectIssuance ? "True" : "False"}</p>
</div>
{ca.status === CaStatus.ACTIVE && (
<ProjectPermissionCan
@@ -156,17 +171,20 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
colorSchema="primary"
type="submit"
onClick={() => {
if (ca.type === CaType.INTERMEDIATE && !ca.parentCaId) {
if (
ca.configuration.type === InternalCaType.INTERMEDIATE &&
!ca.configuration.parentCaId
) {
// intermediate CA with external parent CA
handlePopUpOpen("installCaCert", {
caId,
caId: ca.id,
isParentCaExternal: true
});
return;
}
handlePopUpOpen("renewCa", {
caId
caId: ca.id
});
}}
>
@@ -190,7 +208,7 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
type="submit"
onClick={() => {
handlePopUpOpen("installCaCert", {
caId
caId: ca.id
});
}}
>
@@ -3,7 +3,7 @@ import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { CertAuthDetailsByIDPage } from "./CertAuthDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caId"
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caName"
)({
component: CertAuthDetailsByIDPage,
beforeLoad: ({ context, params }) => {
@@ -5,6 +5,7 @@ import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { ExternalCaSection } from "./components/ExternalCaSection";
import { CaSection } from "./components";
export const CertificateAuthoritiesPage = () => {
@@ -17,7 +18,7 @@ export const CertificateAuthoritiesPage = () => {
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader
title="Certificate Authorities"
description="Manage internal private certificate authorities for issuing and signing certificates, including root and intermediate CAs."
description="Manage certificate authorities for issuing and signing certificates"
/>
<ProjectPermissionCan
renderGuardBanner
@@ -25,6 +26,7 @@ export const CertificateAuthoritiesPage = () => {
a={ProjectPermissionSub.CertificateAuthorities}
>
<CaSection />
<ExternalCaSection />
</ProjectPermissionCan>
</div>
</div>
@@ -41,7 +41,7 @@ export const ExternalCaInstallForm = ({ caId, handlePopUpToggle }: Props) => {
});
const { data: csr } = useGetCaCsr(caId);
const { mutateAsync: importCaCertificate } = useImportCaCertificate();
const { mutateAsync: importCaCertificate } = useImportCaCertificate(currentWorkspace.id);
useEffect(() => {
reset();
@@ -55,7 +55,7 @@ export const InternalCaInstallForm = ({ caId, handlePopUpToggle }: Props) => {
const { data: csr } = useGetCaCsr(caId);
const { mutateAsync: signIntermediate } = useSignIntermediate();
const { mutateAsync: importCaCertificate } = useImportCaCertificate();
const { mutateAsync: importCaCertificate } = useImportCaCertificate(currentWorkspace.id);
const {
control,
@@ -17,10 +17,18 @@ import {
// DatePicker
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { CaType, useCreateCa, useGetCaById, useUpdateCa } from "@app/hooks/api/ca";
import {
CaStatus,
CaType,
InternalCaType,
useCreateCa,
useGetCa,
useUpdateCa
} from "@app/hooks/api/ca";
import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants";
import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { slugSchema } from "@app/lib/schemas";
const isValidDate = (dateString: string) => {
const date = new Date(dateString);
@@ -35,23 +43,31 @@ const getDateTenYearsFromToday = () => {
const schema = z
.object({
type: z.enum([CaType.ROOT, CaType.INTERMEDIATE]),
friendlyName: z.string(),
organization: z.string(),
ou: z.string(),
country: z.string(),
province: z.string(),
locality: z.string(),
commonName: z.string(),
notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }),
maxPathLength: z.string(),
keyAlgorithm: z.enum([
CertKeyAlgorithm.RSA_2048,
CertKeyAlgorithm.RSA_4096,
CertKeyAlgorithm.ECDSA_P256,
CertKeyAlgorithm.ECDSA_P384
]),
requireTemplateForIssuance: z.boolean()
type: z.nativeEnum(CaType),
name: slugSchema({
field: "Name"
}),
enableDirectIssuance: z.boolean(),
status: z.nativeEnum(CaStatus),
configuration: z
.object({
type: z.enum([InternalCaType.ROOT, InternalCaType.INTERMEDIATE]),
organization: z.string(),
ou: z.string(),
country: z.string(),
province: z.string(),
locality: z.string(),
commonName: z.string(),
notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }),
maxPathLength: z.string(),
keyAlgorithm: z.enum([
CertKeyAlgorithm.RSA_2048,
CertKeyAlgorithm.RSA_4096,
CertKeyAlgorithm.ECDSA_P256,
CertKeyAlgorithm.ECDSA_P384
])
})
.required()
})
.required();
@@ -63,15 +79,17 @@ type Props = {
};
const caTypes = [
{ label: "Root", value: CaType.ROOT },
{ label: "Intermediate", value: CaType.INTERMEDIATE }
{ label: "Root", value: InternalCaType.ROOT },
{ label: "Intermediate", value: InternalCaType.INTERMEDIATE }
];
export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
// const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false);
const { data: ca } = useGetCaById((popUp?.ca?.data as { caId: string })?.caId || "");
const { data: ca } = useGetCa({
caName: (popUp?.ca?.data as { name: string })?.name || "",
projectId: currentWorkspace?.id || "",
type: CaType.INTERNAL
});
const { mutateAsync: createMutateAsync } = useCreateCa();
const { mutateAsync: updateMutateAsync } = useUpdateCa();
@@ -85,42 +103,12 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
type: CaType.ROOT,
friendlyName: "",
organization: "",
ou: "",
country: "",
province: "",
locality: "",
commonName: "",
notAfter: getDateTenYearsFromToday(),
maxPathLength: "-1",
keyAlgorithm: CertKeyAlgorithm.RSA_2048
}
});
const caType = watch("type");
useEffect(() => {
if (ca) {
reset({
type: ca.type,
friendlyName: ca.friendlyName,
organization: ca.organization,
ou: ca.ou,
country: ca.country,
province: ca.province,
locality: ca.locality,
commonName: ca.commonName,
notAfter: ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "",
maxPathLength: ca.maxPathLength ? String(ca.maxPathLength) : "",
keyAlgorithm: ca.keyAlgorithm,
requireTemplateForIssuance: ca.requireTemplateForIssuance
});
} else {
reset({
type: CaType.ROOT,
friendlyName: "",
type: CaType.INTERNAL,
name: "",
status: CaStatus.ACTIVE,
enableDirectIssuance: true,
configuration: {
type: InternalCaType.ROOT,
organization: "",
ou: "",
country: "",
@@ -129,25 +117,65 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
commonName: "",
notAfter: getDateTenYearsFromToday(),
maxPathLength: "-1",
keyAlgorithm: CertKeyAlgorithm.RSA_2048,
requireTemplateForIssuance: true
keyAlgorithm: CertKeyAlgorithm.RSA_2048
}
}
});
const caType = watch("configuration.type");
useEffect(() => {
if (ca && ca.type === CaType.INTERNAL) {
reset({
type: ca.type,
name: ca.name,
status: ca.status,
enableDirectIssuance: ca.enableDirectIssuance,
configuration: {
type: ca.configuration.type,
organization: ca.configuration.organization,
ou: ca.configuration.ou,
country: ca.configuration.country,
province: ca.configuration.province,
locality: ca.configuration.locality,
commonName: ca.configuration.commonName,
notAfter: ca.configuration.notAfter
? format(new Date(ca.configuration.notAfter), "yyyy-MM-dd")
: "",
maxPathLength: ca.configuration.maxPathLength
? String(ca.configuration.maxPathLength)
: "",
keyAlgorithm: ca.configuration.keyAlgorithm
}
});
} else {
reset({
type: CaType.INTERNAL,
name: "",
status: CaStatus.ACTIVE,
enableDirectIssuance: true,
configuration: {
type: InternalCaType.ROOT,
organization: "",
ou: "",
country: "",
province: "",
locality: "",
commonName: "",
notAfter: getDateTenYearsFromToday(),
maxPathLength: "-1",
keyAlgorithm: CertKeyAlgorithm.RSA_2048
}
});
}
}, [ca]);
const onFormSubmit = async ({
type,
friendlyName,
commonName,
organization,
ou,
country,
locality,
province,
notAfter,
maxPathLength,
keyAlgorithm,
requireTemplateForIssuance
name,
enableDirectIssuance,
status,
configuration
}: FormData) => {
try {
if (!currentWorkspace?.slug) return;
@@ -155,26 +183,25 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
if (ca) {
// update
await updateMutateAsync({
projectSlug: currentWorkspace.slug,
caId: ca.id,
requireTemplateForIssuance
caName: ca.name,
projectId: currentWorkspace.id,
name,
type: CaType.INTERNAL,
status,
enableDirectIssuance
});
} else {
// create
await createMutateAsync({
projectSlug: currentWorkspace.slug,
projectId: currentWorkspace.id,
name,
type,
friendlyName,
commonName,
organization,
ou,
country,
province,
locality,
notAfter,
maxPathLength: Number(maxPathLength),
keyAlgorithm,
requireTemplateForIssuance
status,
enableDirectIssuance,
configuration: {
...configuration,
maxPathLength: Number(configuration.maxPathLength)
}
});
}
@@ -211,8 +238,8 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
)}
<Controller
control={control}
name="type"
defaultValue={CaType.ROOT}
name="configuration.type"
defaultValue={InternalCaType.ROOT}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="CA Type" errorText={error?.message} isError={Boolean(error)}>
<Select
@@ -231,7 +258,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl>
)}
/>
{caType === CaType.ROOT && (
{caType === InternalCaType.ROOT && (
<>
{/* <Controller
name="notAfter"
@@ -264,7 +291,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller
control={control}
defaultValue=""
name="notAfter"
name="configuration.notAfter"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Valid Until"
@@ -278,7 +305,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
/>
<Controller
control={control}
name="maxPathLength"
name="configuration.maxPathLength"
defaultValue="-1"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
@@ -307,7 +334,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
)}
<Controller
control={control}
name="keyAlgorithm"
name="configuration.keyAlgorithm"
defaultValue={CertKeyAlgorithm.RSA_2048}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
@@ -334,21 +361,22 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller
control={control}
defaultValue=""
name="friendlyName"
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Friendly Name"
label="Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="My CA" isDisabled={Boolean(ca)} />
<Input {...field} placeholder="my-internal-ca" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="organization"
name="configuration.organization"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Organization (O)"
@@ -362,7 +390,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller
control={control}
defaultValue=""
name="ou"
name="configuration.ou"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Organization Unit (OU)"
@@ -376,7 +404,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller
control={control}
defaultValue=""
name="country"
name="configuration.country"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Country Name (C)"
@@ -390,7 +418,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller
control={control}
defaultValue=""
name="province"
name="configuration.province"
render={({ field, fieldState: { error } }) => (
<FormControl
label="State or Province Name"
@@ -404,7 +432,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller
control={control}
defaultValue=""
name="locality"
name="configuration.locality"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Locality Name"
@@ -418,7 +446,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller
control={control}
defaultValue=""
name="commonName"
name="configuration.commonName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Common Name (CN)"
@@ -431,16 +459,16 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
/>
<Controller
control={control}
name="requireTemplateForIssuance"
name="enableDirectIssuance"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message} className="my-8">
<Switch
id="is-active"
id="enable-direct-issuance"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Require Template for Certificate Issuance</p>
<p className="w-full">Enable Direct Issuance</p>
</Switch>
</FormControl>
);
@@ -6,7 +6,7 @@ import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { CaStatus, useDeleteCa, useUpdateCa } from "@app/hooks/api";
import { CaStatus, CaType, useDeleteCa, useUpdateCa } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { CaCertModal } from "./CaCertModal";
@@ -28,11 +28,11 @@ export const CaSection = () => {
"upgradePlan"
] as const);
const onRemoveCaSubmit = async (caId: string) => {
const onRemoveCaSubmit = async (caName: string) => {
try {
if (!currentWorkspace?.slug) return;
await deleteCa({ caId, projectSlug: currentWorkspace.slug });
await deleteCa({ caName, projectId: currentWorkspace.id, type: CaType.INTERNAL });
createNotification({
text: "Successfully deleted CA",
@@ -48,11 +48,11 @@ export const CaSection = () => {
}
};
const onUpdateCaStatus = async ({ caId, status }: { caId: string; status: CaStatus }) => {
const onUpdateCaStatus = async ({ caName, status }: { caName: string; status: CaStatus }) => {
try {
if (!currentWorkspace?.slug) return;
await updateCa({ caId, projectSlug: currentWorkspace.slug, status });
await updateCa({ caName, projectId: currentWorkspace.id, type: CaType.INTERNAL, status });
createNotification({
text: `Successfully ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`,
@@ -72,7 +72,7 @@ export const CaSection = () => {
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">Certificate Authorities</p>
<p className="text-xl font-semibold text-mineshaft-100">Internal Certificate Authorities</p>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.CertificateAuthorities}
@@ -102,7 +102,9 @@ export const CaSection = () => {
subTitle="This action will delete other CAs and certificates below it in your CA hierarchy."
onChange={(isOpen) => handlePopUpToggle("deleteCa", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => onRemoveCaSubmit((popUp?.deleteCa?.data as { caId: string })?.caId)}
onDeleteApproved={() =>
onRemoveCaSubmit((popUp?.deleteCa?.data as { caName: string })?.caName)
}
/>
<DeleteActionModal
isOpen={popUp.caStatus.isOpen}
@@ -119,7 +121,7 @@ export const CaSection = () => {
onChange={(isOpen) => handlePopUpToggle("caStatus", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onUpdateCaStatus(popUp?.caStatus?.data as { caId: string; status: CaStatus })
onUpdateCaStatus(popUp?.caStatus?.data as { caName: string; status: CaStatus })
}
/>
<UpgradePlanModal
@@ -23,12 +23,13 @@ import {
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { CaStatus, useListWorkspaceCas } from "@app/hooks/api";
import { CaStatus, CaType, useListCasByTypeAndProjectId } from "@app/hooks/api";
import {
caStatusToNameMap,
caTypeToNameMap,
getCaStatusBadgeVariant
} from "@app/hooks/api/ca/constants";
import { TInternalCertificateAuthority } from "@app/hooks/api/ca/types";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -39,6 +40,7 @@ type Props = {
>,
data?: {
caId?: string;
caName?: string;
dn?: string;
status?: CaStatus;
description?: string;
@@ -49,9 +51,8 @@ type Props = {
export const CaTable = ({ handlePopUpOpen }: Props) => {
const navigate = useNavigate();
const { currentWorkspace } = useWorkspace();
const { data, isPending } = useListWorkspaceCas({
projectSlug: currentWorkspace?.slug ?? ""
});
const { data, isPending } = useListCasByTypeAndProjectId(CaType.INTERNAL, currentWorkspace.id);
const cas = data as TInternalCertificateAuthority[];
return (
<div>
@@ -59,7 +60,7 @@ export const CaTable = ({ handlePopUpOpen }: Props) => {
<Table>
<THead>
<Tr>
<Th>Friendly Name</Th>
<Th>Name</Th>
<Th>Status</Th>
<Th>Type</Th>
<Th>Valid Until</Th>
@@ -69,33 +70,37 @@ export const CaTable = ({ handlePopUpOpen }: Props) => {
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="project-cas" />}
{!isPending &&
data &&
data.length > 0 &&
data.map((ca) => {
cas &&
cas.length > 0 &&
cas.map((ca) => {
return (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`ca-${ca.id}`}
onClick={() =>
navigate({
to: `/${ProjectType.CertificateManager}/$projectId/ca/$caId` as const,
to: `/${ProjectType.CertificateManager}/$projectId/ca/$caName` as const,
params: {
projectId: currentWorkspace.id,
caId: ca.id
caName: ca.name
}
})
}
>
<Td>{ca.friendlyName}</Td>
<Td>{ca.name}</Td>
<Td>
<Badge variant={getCaStatusBadgeVariant(ca.status)}>
{caStatusToNameMap[ca.status]}
</Badge>
</Td>
<Td>{caTypeToNameMap[ca.type]}</Td>
<Td>{caTypeToNameMap[ca.configuration.type]}</Td>
<Td>
<div className="flex items-center">
<p>{ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "-"}</p>
<p>
{ca.configuration.notAfter
? format(new Date(ca.configuration.notAfter), "yyyy-MM-dd")
: "-"}
</p>
</div>
</Td>
<Td className="flex justify-end">
@@ -172,7 +177,7 @@ export const CaTable = ({ handlePopUpOpen }: Props) => {
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("caStatus", {
caId: ca.id,
caName: ca.name,
status:
ca.status === CaStatus.ACTIVE
? CaStatus.DISABLED
@@ -199,8 +204,7 @@ export const CaTable = ({ handlePopUpOpen }: Props) => {
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteCa", {
caId: ca.id,
dn: ca.dn
caName: ca.name
});
}}
disabled={!isAllowed}
@@ -0,0 +1,410 @@
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,
Select,
SelectItem,
Switch
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useListAvailableAppConnections } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
AcmeDnsProvider,
CaStatus,
CaType,
useCreateCa,
useGetCa,
useUpdateCa
} from "@app/hooks/api/ca";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { slugSchema } from "@app/lib/schemas";
const schema = z
.object({
type: z.nativeEnum(CaType),
name: slugSchema({
field: "Name"
}),
enableDirectIssuance: z.boolean(),
status: z.nativeEnum(CaStatus),
configuration: z.object({
dnsAppConnection: z.object({
id: z.string(),
name: z.string()
}),
// currently specific to Route53 but can be extended to others by differentiating via the provider property
dnsProviderConfig: z.object({
provider: z.nativeEnum(AcmeDnsProvider),
hostedZoneId: z.string()
}),
directoryUrl: z.string(),
accountEmail: z.string()
})
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["ca"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["ca"]>, state?: boolean) => void;
};
const caTypes = [{ label: "ACME", value: CaType.ACME }];
export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data: ca } = useGetCa({
caName: (popUp?.ca?.data as { name: string })?.name || "",
projectId: currentWorkspace?.id || "",
type: (popUp?.ca?.data as { type: CaType })?.type || ""
});
const { mutateAsync: createMutateAsync } = useCreateCa();
const { mutateAsync: updateMutateAsync } = useUpdateCa();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting },
watch
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
type: CaType.ACME,
name: "",
status: CaStatus.ACTIVE,
enableDirectIssuance: true,
configuration: {
dnsAppConnection: {
id: "",
name: ""
},
dnsProviderConfig: {
provider: AcmeDnsProvider.ROUTE53,
hostedZoneId: ""
},
directoryUrl: "",
accountEmail: ""
}
}
});
const caType = watch("type");
const dnsProvider = watch("configuration.dnsProviderConfig.provider");
const { data: availableConnections, isPending } = useListAvailableAppConnections(
AppConnection.AWS,
{
enabled: dnsProvider === AcmeDnsProvider.ROUTE53
}
);
useEffect(() => {
if (ca) {
if (ca.type !== CaType.INTERNAL && availableConnections?.length) {
const selectedConnection = availableConnections?.find(
(connection) => connection.id === ca?.configuration.dnsAppConnectionId
);
reset({
type: ca.type,
name: ca.name,
status: ca.status,
enableDirectIssuance: ca.enableDirectIssuance,
configuration: {
dnsAppConnection: {
id: ca.configuration.dnsAppConnectionId,
name: selectedConnection?.name || ""
},
dnsProviderConfig: {
provider: ca.configuration.dnsProviderConfig.provider,
hostedZoneId: ca.configuration.dnsProviderConfig.hostedZoneId
},
directoryUrl: ca.configuration.directoryUrl,
accountEmail: ca.configuration.accountEmail
}
});
}
} else {
reset({
type: CaType.ACME,
name: "",
status: CaStatus.ACTIVE,
enableDirectIssuance: true,
configuration: {
dnsAppConnection: {
id: "",
name: ""
},
dnsProviderConfig: {
provider: AcmeDnsProvider.ROUTE53,
hostedZoneId: ""
},
directoryUrl: "",
accountEmail: ""
}
});
}
}, [ca, availableConnections]);
const onFormSubmit = async ({
type,
name,
enableDirectIssuance,
status,
configuration
}: FormData) => {
try {
if (!currentWorkspace?.slug) return;
if (ca && type !== CaType.INTERNAL) {
await updateMutateAsync({
caName: ca.name,
projectId: currentWorkspace.id,
name,
type,
status,
enableDirectIssuance,
configuration: {
...configuration,
dnsAppConnectionId: configuration.dnsAppConnection.id
}
});
} else {
await createMutateAsync({
projectId: currentWorkspace.id,
name,
type,
status,
enableDirectIssuance,
configuration: {
...configuration,
dnsAppConnectionId: configuration.dnsAppConnection.id
}
});
}
reset();
handlePopUpToggle("ca", false);
createNotification({
text: `Successfully ${ca ? "updated" : "created"} CA`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create CA",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.ca?.isOpen}
onOpenChange={(isOpen) => {
reset();
handlePopUpToggle("ca", isOpen);
}}
>
<ModalContent title={`${ca ? "View" : "Create"} External CA`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
{ca && (
<FormControl label="CA ID">
<Input value={ca.id} isDisabled className="bg-white/[0.07]" />
</FormControl>
)}
<Controller
control={control}
name="type"
defaultValue={CaType.ACME}
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"
isDisabled={Boolean(ca)}
>
{caTypes.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="my-external-ca" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
{caType === CaType.ACME && (
<>
<Controller
control={control}
name="configuration.dnsProviderConfig.provider"
defaultValue={AcmeDnsProvider.ROUTE53}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="DNS Provider"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(ca)}
>
<SelectItem
value={String(AcmeDnsProvider.ROUTE53)}
key={AcmeDnsProvider.ROUTE53}
>
Route53
</SelectItem>
</Select>
</FormControl>
)}
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`${dnsProvider === AcmeDnsProvider.ROUTE53 ? "Route53" : ""} requires an AWS App Connection. This can be created from the Organization Settings page.`}
isError={Boolean(error)}
errorText={error?.message}
label="DNS App Connection"
>
<FilterableSelect
value={value}
onChange={(newValue) => {
onChange(newValue);
}}
isLoading={isPending}
options={availableConnections}
placeholder="Select connection..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
control={control}
name="configuration.dnsAppConnection"
/>
<Controller
control={control}
defaultValue=""
name="configuration.dnsProviderConfig.hostedZoneId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Hosted Zone ID"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="Z040441124N1GOOMCQYX1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="configuration.directoryUrl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Directory URL"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input
{...field}
placeholder="https://acme-v02.api.letsencrypt.org/directory"
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="configuration.accountEmail"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Account Email"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="[email protected]" />
</FormControl>
)}
/>
</>
)}
<Controller
control={control}
name="enableDirectIssuance"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message} className="my-8">
<Switch
id="is-active"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Enable Direct Issuance</p>
</Switch>
</FormControl>
);
}}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.ca?.data ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("ca", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,141 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { CaStatus, CaType, useDeleteCa, useUpdateCa } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { ExternalCaModal } from "./ExternalCaModal";
import { ExternalCaTable } from "./ExternalCaTable";
export const ExternalCaSection = () => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync: deleteCa } = useDeleteCa();
const { mutateAsync: updateCa } = useUpdateCa();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"ca",
"deleteCa",
"caStatus", // enable / disable
"upgradePlan"
] as const);
const onRemoveCaSubmit = async (caName: string, type: CaType) => {
try {
if (!currentWorkspace?.id) return;
await deleteCa({ caName, type, projectId: currentWorkspace.id });
createNotification({
text: "Successfully deleted CA",
type: "success"
});
handlePopUpClose("deleteCa");
} catch {
createNotification({
text: "Failed to delete CA",
type: "error"
});
}
};
const onUpdateCaStatus = async ({
name,
type,
status
}: {
name: string;
type: CaType;
status: CaStatus;
}) => {
try {
if (!currentWorkspace?.slug) return;
await updateCa({ caName: name, type, status, projectId: currentWorkspace.id });
createNotification({
text: `Successfully ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`,
type: "success"
});
handlePopUpClose("caStatus");
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${status === CaStatus.ACTIVE ? "enable" : "disable"} CA`,
type: "error"
});
}
};
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">External Certificate Authorities</p>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<Button
colorSchema="primary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("ca")}
isDisabled={!isAllowed}
>
Create CA
</Button>
)}
</ProjectPermissionCan>
</div>
<ExternalCaModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<ExternalCaTable handlePopUpOpen={handlePopUpOpen} />
<DeleteActionModal
isOpen={popUp.deleteCa.isOpen}
title={`Are you sure want to remove the CA ${
(popUp?.deleteCa?.data as { dn: string })?.dn || ""
} from the project?`}
subTitle="This action will delete other CAs and certificates below it in your CA hierarchy."
onChange={(isOpen) => handlePopUpToggle("deleteCa", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveCaSubmit(
(popUp?.deleteCa?.data as { name: string })?.name,
(popUp?.deleteCa?.data as { type: CaType })?.type
)
}
/>
<DeleteActionModal
isOpen={popUp.caStatus.isOpen}
title={`Are you sure want to ${
(popUp?.caStatus?.data as { status: string })?.status === CaStatus.ACTIVE
? "enable"
: "disable"
} the CA ${(popUp?.caStatus?.data as { dn: string })?.dn || ""} from the project?`}
subTitle={
(popUp?.caStatus?.data as { status: string })?.status === CaStatus.ACTIVE
? "This action will allow the CA to start issuing certificates again."
: "This action will prevent the CA from issuing new certificates."
}
onChange={(isOpen) => handlePopUpToggle("caStatus", isOpen)}
buttonText="Proceed"
deleteKey="confirm"
onDeleteApproved={() =>
onUpdateCaStatus(
popUp?.caStatus?.data as { name: string; type: CaType; status: CaStatus }
)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
);
};
@@ -0,0 +1,189 @@
import {
faBan,
faCertificate,
faEllipsis,
faPencil,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { CaStatus, CaType, useListCasByTypeAndProjectId } from "@app/hooks/api";
import { caStatusToNameMap, getCaStatusBadgeVariant } from "@app/hooks/api/ca/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["ca", "deleteCa", "caStatus", "upgradePlan"]>,
data?: {
name?: string;
type?: CaType;
status?: CaStatus;
description?: string;
}
) => void;
};
export const ExternalCaTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data, isPending } = useListCasByTypeAndProjectId(CaType.ACME, currentWorkspace.id);
return (
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Type</Th>
<Th>Status</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="project-cas" />}
{!isPending &&
data &&
data.length > 0 &&
data.map((ca) => {
return (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`ca-${ca.id}`}
onClick={() => {
handlePopUpOpen("ca", {
name: ca.name,
type: ca.type
});
}}
>
<Td>{ca.name}</Td>
<Td>{ca.type}</Td>
<Td>
<Badge variant={getCaStatusBadgeVariant(ca.status)}>
{caStatusToNameMap[ca.status]}
</Badge>
</Td>
<Td className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("ca", {
name: ca.name,
type: ca.type
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faPencil} />}
>
Edit CA
</DropdownMenuItem>
)}
</ProjectPermissionCan>
{(ca.status === CaStatus.ACTIVE || ca.status === CaStatus.DISABLED) && (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("caStatus", {
name: ca.name,
type: ca.type,
status:
ca.status === CaStatus.ACTIVE
? CaStatus.DISABLED
: CaStatus.ACTIVE
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faBan} />}
>
{`${ca.status === CaStatus.ACTIVE ? "Disable" : "Enable"} CA`}
</DropdownMenuItem>
)}
</ProjectPermissionCan>
)}
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteCa", {
name: ca.name,
type: ca.type
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete CA
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && data?.length === 0 && (
<EmptyState
title="No external certificate authorities have been created"
icon={faCertificate}
/>
)}
</TableContainer>
</div>
);
};
@@ -0,0 +1,244 @@
import { useState } 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,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useGetCert, useImportCertificate, useListWorkspacePkiCollections } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { CertificateContent } from "./CertificateContent";
const schema = z.object({
certificatePem: z.string().trim().min(1, "Certificate PEM is required"),
privateKeyPem: z.string().trim().min(1, "Private Key PEM is required"),
chainPem: z.string().trim().min(1, "Certificate Chain PEM is required"),
friendlyName: z.string(),
collectionId: z.string().optional()
});
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["certificateImport"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["certificateImport"]>,
state?: boolean
) => void;
};
type TCertificateDetails = {
serialNumber: string;
certificate: string;
certificateChain: string;
privateKey: string;
};
export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => {
const [certificateDetails, setCertificateDetails] = useState<TCertificateDetails | null>(null);
const { currentWorkspace } = useWorkspace();
const { data: cert } = useGetCert(
(popUp?.certificateImport?.data as { serialNumber: string })?.serialNumber || ""
);
const { data } = useListWorkspacePkiCollections({
workspaceId: currentWorkspace?.id || ""
});
const { mutateAsync: importCertificate } = useImportCertificate();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
const onFormSubmit = async ({
certificatePem,
privateKeyPem,
chainPem,
friendlyName,
collectionId
}: FormData) => {
try {
if (!currentWorkspace?.slug) return;
const { serialNumber, certificate, certificateChain, privateKey } = await importCertificate({
projectSlug: currentWorkspace.slug,
certificatePem,
privateKeyPem,
chainPem,
friendlyName,
pkiCollectionId: collectionId
});
reset();
setCertificateDetails({
serialNumber,
certificate,
certificateChain,
privateKey
});
createNotification({
text: "Successfully imported certificate",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to import certificate",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.certificateImport?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("certificateImport", isOpen);
reset();
setCertificateDetails(null);
}}
>
<ModalContent title={`${cert ? "View" : "Import"} Certificate`}>
{!certificateDetails ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="collectionId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Certificate Collection"
errorText={error?.message}
isError={Boolean(error)}
isOptional
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(cert)}
>
{(data?.collections || []).map(({ id, name }) => (
<SelectItem value={id} key={`pki-collection-${id}`}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="friendlyName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Friendly Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="My Certificate" isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="certificatePem"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Leaf Certificate PEM"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<TextArea {...field} isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="privateKeyPem"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Private Key PEM"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<TextArea {...field} isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="chainPem"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Certificate Chain PEM"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<TextArea {...field} isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
{!cert && (
<div className="mt-4 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("certificateImport", false)}
>
Cancel
</Button>
</div>
)}
</form>
) : (
<CertificateContent
serialNumber={certificateDetails.serialNumber}
certificate={certificateDetails.certificate}
certificateChain={certificateDetails.certificateChain}
privateKey={certificateDetails.privateKey}
/>
)}
</ModalContent>
</Modal>
);
};
@@ -9,7 +9,11 @@ import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, IconButton } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import {
ProjectPermissionPkiTemplateActions,
ProjectPermissionSub,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteCertTemplate } from "@app/hooks/api";
@@ -63,7 +67,7 @@ export const CertificateTemplatesSection = ({ caId }: Props) => {
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Certificate Templates</h3>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
I={ProjectPermissionPkiTemplateActions.Create}
a={ProjectPermissionSub.CertificateTemplates}
>
{(isAllowed) => (
@@ -19,7 +19,11 @@ import {
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
import {
ProjectPermissionPkiTemplateActions,
ProjectPermissionSub,
useSubscription
} from "@app/context";
import { useGetCaCertTemplates } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -79,7 +83,7 @@ export const CertificateTemplatesTable = ({ handlePopUpOpen, caId }: Props) => {
Manage Policies
</DropdownMenuItem>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
I={ProjectPermissionPkiTemplateActions.Edit}
a={ProjectPermissionSub.CertificateTemplates}
>
{(isAllowed) => (
@@ -105,7 +109,7 @@ export const CertificateTemplatesTable = ({ handlePopUpOpen, caId }: Props) => {
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
I={ProjectPermissionPkiTemplateActions.Delete}
a={ProjectPermissionSub.CertificateTemplates}
>
{(isAllowed) => (
@@ -1,4 +1,4 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { faArrowRight, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
@@ -13,6 +13,7 @@ import { useDeleteCert } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { CertificateCertModal } from "./CertificateCertModal";
import { CertificateImportModal } from "./CertificateImportModal";
import { CertificateModal } from "./CertificateModal";
import { CertificateRevocationModal } from "./CertificateRevocationModal";
import { CertificatesTable } from "./CertificatesTable";
@@ -23,6 +24,7 @@ export const CertificatesSection = () => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"certificate",
"certificateImport",
"certificateCert",
"deleteCertificate",
"revokeCertificate"
@@ -58,20 +60,31 @@ export const CertificatesSection = () => {
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificate")}
isDisabled={!isAllowed}
>
Issue
</Button>
<div className="flex gap-2">
<Button
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faArrowRight} />}
onClick={() => handlePopUpOpen("certificateImport")}
isDisabled={!isAllowed}
>
Import
</Button>
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificate")}
isDisabled={!isAllowed}
>
Issue
</Button>
</div>
)}
</ProjectPermissionCan>
</div>
<CertificatesTable handlePopUpOpen={handlePopUpOpen} />
<CertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateImportModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
@@ -48,12 +48,17 @@ export const PkiSubscriberCertificatesTable = ({ subscriberName, handlePopUpOpen
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(PER_PAGE_INIT);
const { data, isPending } = useGetPkiSubscriberCertificates({
subscriberName,
projectId,
offset: (page - 1) * perPage,
limit: perPage
});
const { data, isPending } = useGetPkiSubscriberCertificates(
{
subscriberName,
projectId,
offset: (page - 1) * perPage,
limit: perPage
},
{
refetchInterval: 5000
}
);
const getCertStatusBadge = (status: string, notAfter: string) => {
if (status === CertStatus.REVOKED) {
@@ -5,7 +5,14 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, IconButton, Modal, ModalContent, Tooltip } from "@app/components/v2";
import {
Button,
GenericFieldLabel,
IconButton,
Modal,
ModalContent,
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionPkiSubscriberActions,
ProjectPermissionSub,
@@ -13,8 +20,13 @@ import {
useWorkspace
} from "@app/context";
import { useTimedReset } from "@app/hooks";
import { useGetPkiSubscriber, useIssuePkiSubscriberCert } from "@app/hooks/api";
import {
useGetPkiSubscriber,
useIssuePkiSubscriberCert,
useOrderPkiSubscriberCert
} from "@app/hooks/api";
import { pkiSubscriberStatusToNameMap } from "@app/hooks/api/pkiSubscriber/constants";
import { SubscriberOperationStatus } from "@app/hooks/api/pkiSubscriber/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { CertificateContent } from "../../CertificatesPage/components/CertificateContent";
@@ -41,31 +53,48 @@ export const PkiSubscriberDetailsSection = ({ subscriberName, handlePopUpOpen }:
initialState: "Copy ID to clipboard"
});
const { data: pkiSubscriber } = useGetPkiSubscriber({
subscriberName,
projectId
});
const { data: pkiSubscriber } = useGetPkiSubscriber(
{
subscriberName,
projectId
},
{
refetchInterval: 5000
}
);
const { mutateAsync: issuePkiSubscriberCert, isPending: isIssuingCert } =
useIssuePkiSubscriberCert();
const { mutateAsync: orderPkiSubscriberCert, isPending: isOrderingCert } =
useOrderPkiSubscriberCert();
const onIssuePkiSubscriberCert = async () => {
try {
const response = await issuePkiSubscriberCert({ subscriberName, projectId });
if (pkiSubscriber?.supportsImmediateCertIssuance) {
const response = await issuePkiSubscriberCert({ subscriberName, projectId });
setCertificateDetails({
serialNumber: response.serialNumber,
certificate: response.certificate,
certificateChain: response.certificateChain,
privateKey: response.privateKey
});
setCertificateDetails({
serialNumber: response.serialNumber,
certificate: response.certificate,
certificateChain: response.certificateChain,
privateKey: response.privateKey
});
setIsModalOpen(true);
setIsModalOpen(true);
createNotification({
text: "Successfully issued certificate",
type: "success"
});
createNotification({
text: "Successfully issued certificate",
type: "success"
});
} else {
await orderPkiSubscriberCert({ subscriberName, projectId });
createNotification({
text: "Successfully ordered certificate. It will be issued after CA processing which could take a few minutes.",
type: "info"
});
}
} catch (err) {
console.error(err);
createNotification({
@@ -148,17 +177,36 @@ export const PkiSubscriberDetailsSection = ({ subscriberName, handlePopUpOpen }:
<p className="text-sm font-semibold text-mineshaft-300">Common Name</p>
<p className="text-sm text-mineshaft-300">{pkiSubscriber.commonName}</p>
</div>
{pkiSubscriber.lastOperationAt && (
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Last Operation (Local Time)</p>
<p className="text-sm text-mineshaft-300">
{new Date(pkiSubscriber.lastOperationAt).toLocaleString()}
</p>
</div>
)}
{pkiSubscriber.lastOperationStatus === SubscriberOperationStatus.FAILED && (
<div className="mb-4">
<GenericFieldLabel labelClassName="text-red" label="Last Operation Status">
<p className="break-words rounded bg-mineshaft-600 p-2 text-xs">
{pkiSubscriber.lastOperationMessage}
</p>
</GenericFieldLabel>
</div>
)}
{canIssuePkiSubscriberCert && (
<Button
className="mt-4 w-full"
className="mt-2 w-full"
colorSchema="primary"
type="button"
isLoading={isIssuingCert}
isLoading={isIssuingCert || isOrderingCert}
onClick={() => {
onIssuePkiSubscriberCert();
}}
>
Issue Certificate
{pkiSubscriber?.supportsImmediateCertIssuance
? "Issue Certificate"
: "Order Certificate"}
</Button>
)}
</div>
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
@@ -16,14 +16,18 @@ import {
Modal,
ModalContent,
Select,
SelectItem
SelectItem,
Tab,
TabList,
TabPanel,
Tabs
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
CaStatus,
CaType,
useCreatePkiSubscriber,
useGetPkiSubscriber,
useListWorkspaceCas,
useListCasByProjectId,
useListWorkspacePkiSubscribers,
useUpdatePkiSubscriber
} from "@app/hooks/api";
@@ -33,19 +37,25 @@ import {
} from "@app/hooks/api/certificates/constants";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { convertTimeUnitValueToDays, TimeUnit } from "@app/lib/fn/date";
type Props = {
popUp: UsePopUpState<["pkiSubscriber"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["pkiSubscriber"]>, state?: boolean) => void;
};
enum FormTab {
Configuration = "configuration",
Advanced = "advanced"
}
const schema = z
.object({
name: z.string().trim().min(1, "Name is required"),
caId: z.string().min(1, "Issuing CA is required"),
commonName: z.string().trim().min(1, "Common Name is required"),
subjectAlternativeNames: z.string(),
ttl: z.string().trim(),
ttl: z.string().trim().optional(),
keyUsages: z.object({
[CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(),
[CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(),
@@ -64,7 +74,10 @@ const schema = z
[CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(),
[CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(),
[CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional()
})
}),
enableAutoRenewal: z.boolean().optional().default(false),
renewalBefore: z.number().min(1).optional(),
renewalUnit: z.nativeEnum(TimeUnit).optional()
})
.required();
@@ -74,10 +87,8 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace.id;
const { data: subscribers } = useListWorkspacePkiSubscribers(projectId);
const { data: cas } = useListWorkspaceCas({
projectSlug: currentWorkspace?.slug ?? "",
status: CaStatus.ACTIVE
});
const { data: cas } = useListCasByProjectId(projectId);
const [tabValue, setTabValue] = useState<FormTab>(FormTab.Configuration);
const { data: pkiSubscriber } = useGetPkiSubscriber({
subscriberName:
@@ -93,6 +104,7 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
handleSubmit,
reset,
setValue,
watch,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
@@ -106,10 +118,17 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
[CertKeyUsage.KEY_ENCIPHERMENT]: true
},
extendedKeyUsages: {}
extendedKeyUsages: {},
enableAutoRenewal: false,
renewalBefore: 7,
renewalUnit: TimeUnit.DAY
}
});
const selectedCaId = watch("caId");
const selectedCa = cas?.find((ca) => ca.id === selectedCaId);
const selectedAutoRenewalState = watch("enableAutoRenewal");
useEffect(() => {
if (pkiSubscriber) {
reset({
@@ -121,7 +140,10 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
keyUsages: Object.fromEntries((pkiSubscriber.keyUsages || []).map((name) => [name, true])),
extendedKeyUsages: Object.fromEntries(
(pkiSubscriber.extendedKeyUsages || []).map((name) => [name, true])
)
),
enableAutoRenewal: pkiSubscriber.enableAutoRenewal || false,
renewalBefore: pkiSubscriber.autoRenewalPeriodInDays || 7,
renewalUnit: TimeUnit.DAY
});
} else {
reset({
@@ -134,7 +156,10 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
[CertKeyUsage.KEY_ENCIPHERMENT]: true
},
extendedKeyUsages: {}
extendedKeyUsages: {},
enableAutoRenewal: false,
renewalBefore: 7,
renewalUnit: TimeUnit.DAY
});
}
}, [pkiSubscriber, reset]);
@@ -152,7 +177,10 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
subjectAlternativeNames,
ttl,
keyUsages,
extendedKeyUsages
extendedKeyUsages,
enableAutoRenewal,
renewalBefore,
renewalUnit
}: FormData) => {
try {
if (!projectId) return;
@@ -190,6 +218,10 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
.map((san) => san.trim())
.filter(Boolean);
const autoRenewalPeriodInDays = enableAutoRenewal
? convertTimeUnitValueToDays(renewalUnit, renewalBefore)
: undefined;
if (pkiSubscriber) {
await updateMutateAsync({
subscriberName: pkiSubscriber.name,
@@ -200,7 +232,9 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
subjectAlternativeNames: subjectAlternativeNamesList,
ttl,
keyUsages: keyUsagesList,
extendedKeyUsages: extendedKeyUsagesList
extendedKeyUsages: extendedKeyUsagesList,
enableAutoRenewal,
autoRenewalPeriodInDays
});
} else {
await createMutateAsync({
@@ -211,7 +245,9 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
subjectAlternativeNames: subjectAlternativeNamesList,
ttl,
keyUsages: keyUsagesList,
extendedKeyUsages: extendedKeyUsagesList
extendedKeyUsages: extendedKeyUsagesList,
enableAutoRenewal,
autoRenewalPeriodInDays
});
}
@@ -240,169 +276,271 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
}}
>
<ModalContent title={`${pkiSubscriber ? "Update" : "Add"} PKI Subscriber`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
{pkiSubscriber && (
<FormControl label="Subscriber ID">
<Input value={pkiSubscriber.id} isDisabled className="bg-white/[0.07]" />
</FormControl>
)}
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Subscriber Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="web-service" />
</FormControl>
)}
/>
<Controller
control={control}
name="caId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Issuing CA"
errorText={error?.message}
isError={Boolean(error)}
isRequired
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(cas || []).map(({ id, dn }) => (
<SelectItem value={id} key={`ca-${id}`}>
{dn}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="commonName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Common Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="web.example.com" />
</FormControl>
)}
/>
<Controller
control={control}
name="subjectAlternativeNames"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Subject Alternative Names (SANs)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="app1.example.com, app2.example.com, ..." />
</FormControl>
)}
/>
<Controller
control={control}
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="TTL"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="2 days, 1d, 2h, 1y, ..." />
</FormControl>
)}
/>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="key-usages" className="data-[state=open]:border-none">
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
<div className="order-1 ml-3">Key Usage</div>
</AccordionTrigger>
<AccordionContent>
<form
onSubmit={handleSubmit(onFormSubmit, (fields) => {
setTabValue(
["name", "caId", "commonName", "subjectAlternativeNames", "ttl"].includes(
Object.keys(fields)[0]
)
? FormTab.Configuration
: FormTab.Advanced
);
})}
>
<Tabs value={tabValue} onValueChange={(value) => setTabValue(value as FormTab)}>
<TabList>
<Tab value={FormTab.Configuration}>Configuration</Tab>
<Tab value={FormTab.Advanced}>Advanced</Tab>
</TabList>
<TabPanel value={FormTab.Configuration}>
{pkiSubscriber && (
<FormControl label="Subscriber ID">
<Input value={pkiSubscriber.id} isDisabled className="bg-white/[0.07]" />
</FormControl>
)}
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Subscriber Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="web-service" />
</FormControl>
)}
/>
<Controller
control={control}
name="caId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Issuing CA"
errorText={error?.message}
isError={Boolean(error)}
isRequired
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(cas || []).map(({ id, name, type, configuration }) => {
const displayName =
type === CaType.INTERNAL ? `${name} (${configuration.dn})` : name;
return (
<SelectItem value={id} key={`ca-${id}`}>
{displayName}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="commonName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Common Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="web.example.com" />
</FormControl>
)}
/>
<Controller
control={control}
name="subjectAlternativeNames"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Subject Alternative Names (SANs)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="app1.example.com, app2.example.com, ..." />
</FormControl>
)}
/>
{selectedCa?.type !== CaType.ACME && (
<Controller
control={control}
name="keyUsages"
render={({ field: { onChange, value }, fieldState: { error } }) => {
return (
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="TTL"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="2 days, 1d, 2h, 1y, ..." />
</FormControl>
)}
/>
)}
{selectedCa?.type !== CaType.ACME && (
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="key-usages" className="data-[state=open]:border-none">
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
<div className="order-1 ml-3">Key Usage</div>
</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="keyUsages"
render={({ field: { onChange, value }, fieldState: { error } }) => {
return (
<FormControl
label="Key Usage"
errorText={error?.message}
isError={Boolean(error)}
>
<div className="mb-7 mt-2 grid grid-cols-2 gap-2">
{KEY_USAGES_OPTIONS.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={optionValue}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</div>
</FormControl>
);
}}
/>
<Controller
control={control}
name="extendedKeyUsages"
render={({ field: { onChange, value }, fieldState: { error } }) => {
return (
<FormControl
label="Extended Key Usage"
errorText={error?.message}
isError={Boolean(error)}
>
<div className="mb-7 mt-2 grid grid-cols-2 gap-2">
{EXTENDED_KEY_USAGES_OPTIONS.map(
({ label, value: optionValue }) => {
return (
<Checkbox
id={optionValue}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
}
)}
</div>
</FormControl>
);
}}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</TabPanel>
<TabPanel value={FormTab.Advanced}>
<Controller
control={control}
name="enableAutoRenewal"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
tooltipText="If enabled, a new certificate will be issued automatically X days before the current certificate expires."
>
<Checkbox
id="enableAutoRenewal"
isChecked={value}
onCheckedChange={onChange}
className="data-[state=checked]:bg-primary"
>
Enable Certificate Auto Renewal
</Checkbox>
</FormControl>
)}
/>
{selectedAutoRenewalState && (
<div className="flex items-center">
<Controller
control={control}
defaultValue={7}
name="renewalBefore"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Key Usage"
label="Renewal Before"
isError={Boolean(error)}
errorText={error?.message}
className="w-full"
isRequired
>
<Input
{...field}
placeholder="5"
type="number"
min={1}
onChange={(e) => onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="renewalUnit"
defaultValue={TimeUnit.DAY}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="ml-4"
label="Unit"
errorText={error?.message}
isError={Boolean(error)}
>
<div className="mb-7 mt-2 grid grid-cols-2 gap-2">
{KEY_USAGES_OPTIONS.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={optionValue}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</div>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-48"
>
<SelectItem value={TimeUnit.DAY}>Days</SelectItem>
<SelectItem value={TimeUnit.WEEK}>Weeks</SelectItem>
<SelectItem value={TimeUnit.MONTH}>Months</SelectItem>
<SelectItem value={TimeUnit.YEAR}>Years</SelectItem>
</Select>
</FormControl>
);
}}
/>
<Controller
control={control}
name="extendedKeyUsages"
render={({ field: { onChange, value }, fieldState: { error } }) => {
return (
<FormControl
label="Extended Key Usage"
errorText={error?.message}
isError={Boolean(error)}
>
<div className="mb-7 mt-2 grid grid-cols-2 gap-2">
{EXTENDED_KEY_USAGES_OPTIONS.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={optionValue}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</div>
</FormControl>
);
}}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
/>
</div>
)}
</TabPanel>
</Tabs>
<div className="mt-4 flex items-center">
<Button
className="mr-4"
@@ -0,0 +1,257 @@
import { useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import {
faCertificate,
faEllipsis,
faPencil,
faPlus,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Modal,
ModalContent,
PageHeader,
Pagination,
Table,
TableContainer,
TableSkeleton,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import {
ProjectPermissionPkiTemplateActions,
ProjectPermissionSub,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteCertTemplateV2 } from "@app/hooks/api";
import { useListCertificateTemplates } from "@app/hooks/api/certificateTemplates/queries";
import { PkiTemplateForm } from "./components/PkiTemplateForm";
const PER_PAGE_INIT = 25;
export const PkiTemplateListPage = () => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(PER_PAGE_INIT);
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"certificateTemplate",
"deleteTemplate"
] as const);
const { data, isPending } = useListCertificateTemplates({
projectId: currentWorkspace.id,
offset: (page - 1) * perPage,
limit: perPage
});
const deleteCertTemplate = useDeleteCertTemplateV2();
const onRemovePkiSubscriberSubmit = async () => {
try {
const pkiTemplate = await deleteCertTemplate.mutateAsync({
projectId: currentWorkspace.id,
templateName: popUp?.deleteTemplate?.data?.name
});
createNotification({
text: `Successfully deleted PKI template: ${pkiTemplate.name}`,
type: "success"
});
handlePopUpClose("deleteTemplate");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete PKI subscriber",
type: "error"
});
}
};
return (
<>
<Helmet>
<title>{t("common.head-title", { title: "PKI Subscribers" })}</title>
</Helmet>
<div className="h-full bg-bunker-800">
<div className="container mx-auto flex flex-col justify-between text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader
title="Certificate Templates"
description="Manage certificate template to request and issue dynamic certificates following a strict format."
/>
</div>
<div className="container mx-auto mb-6 max-w-7xl 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">Templates</p>
<div className="flex w-full justify-end">
<ProjectPermissionCan
I={ProjectPermissionPkiTemplateActions.Create}
a={ProjectPermissionSub.CertificateTemplates}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificateTemplate")}
isDisabled={!isAllowed}
className="ml-4"
>
Add Template
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Issuing CA</Th>
<Th className="w-64">Last Updated At</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="project-cert-templates" />}
{!isPending &&
data?.certificateTemplates?.map((template) => {
return (
<Tr className="h-10" key={`certificate-template-${template.id}`}>
<Td>{template.name}</Td>
<Td>
<Tag size="xs">{template.ca.name}</Tag>
</Td>
<Td>{format(new Date(template.updatedAt), "yyyy-MM-dd | HH:mm:ss")}</Td>
<Td className="text-right align-middle">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionPkiTemplateActions.Edit}
a={ProjectPermissionSub.CertificateTemplates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("certificateTemplate", template);
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faPencil} />}
>
Edit Template
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionPkiTemplateActions.Delete}
a={ProjectPermissionSub.CertificateTemplates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteTemplate", template);
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Template
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
{!isPending && !data?.certificateTemplates?.length && (
<Tr>
<Td colSpan={4}>
<EmptyState title="No certificate templates found" icon={faCertificate} />
</Td>
</Tr>
)}
</TBody>
</Table>
{!isPending && data?.totalCount !== undefined && data.totalCount >= PER_PAGE_INIT && (
<Pagination
count={data.totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
</TableContainer>
<DeleteActionModal
isOpen={popUp.deleteTemplate.isOpen}
title="Are you sure you want to remove the PKI Template?"
onChange={(isOpen) => handlePopUpToggle("deleteTemplate", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => onRemovePkiSubscriberSubmit()}
/>
</div>
<div className="container mx-auto max-w-7xl" />
</div>
<Modal
isOpen={popUp?.certificateTemplate?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("certificateTemplate", isOpen)}
>
<ModalContent
title={
popUp.certificateTemplate?.data
? "Certificate Template"
: "Create Certificate Template"
}
>
<PkiTemplateForm
certTemplate={popUp?.certificateTemplate?.data}
handlePopUpToggle={(isOpen) => handlePopUpToggle("certificateTemplate", isOpen)}
/>
</ModalContent>
</Modal>
</div>
</>
);
};
@@ -0,0 +1,407 @@
import { Controller, useForm } from "react-hook-form";
import { faQuestionCircle } from "@fortawesome/free-regular-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 {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
Checkbox,
FilterableSelect,
FormControl,
FormLabel,
Input,
Tooltip
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
useCreateCertTemplateV2,
useListCasByProjectId,
useUpdateCertTemplateV2
} from "@app/hooks/api";
import {
EXTENDED_KEY_USAGES_OPTIONS,
KEY_USAGES_OPTIONS
} from "@app/hooks/api/certificates/constants";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
import { TCertificateTemplateV2 } from "@app/hooks/api/certificateTemplates/types";
import { slugSchema } from "@app/lib/schemas";
const validateTemplateRegexField = z.string().trim().min(1).max(100);
const schema = z.object({
ca: z.object({
name: z.string(),
id: z.string()
}),
name: slugSchema(),
commonName: validateTemplateRegexField,
subjectAlternativeName: validateTemplateRegexField,
ttl: z.string().trim().min(1),
keyUsages: z.object({
[CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(),
[CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(),
[CertKeyUsage.NON_REPUDIATION]: z.boolean().optional(),
[CertKeyUsage.DATA_ENCIPHERMENT]: z.boolean().optional(),
[CertKeyUsage.KEY_AGREEMENT]: z.boolean().optional(),
[CertKeyUsage.KEY_CERT_SIGN]: z.boolean().optional(),
[CertKeyUsage.CRL_SIGN]: z.boolean().optional(),
[CertKeyUsage.ENCIPHER_ONLY]: z.boolean().optional(),
[CertKeyUsage.DECIPHER_ONLY]: z.boolean().optional()
}),
extendedKeyUsages: z.object({
[CertExtendedKeyUsage.CLIENT_AUTH]: z.boolean().optional(),
[CertExtendedKeyUsage.CODE_SIGNING]: z.boolean().optional(),
[CertExtendedKeyUsage.EMAIL_PROTECTION]: z.boolean().optional(),
[CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(),
[CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(),
[CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional()
})
});
export type FormData = z.infer<typeof schema>;
type Props = {
certTemplate?: TCertificateTemplateV2;
handlePopUpToggle: (state?: boolean) => void;
};
export const PkiTemplateForm = ({ certTemplate, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data: cas, isPending: isCaLoading } = useListCasByProjectId(currentWorkspace.id);
const { mutateAsync: createCertTemplate } = useCreateCertTemplateV2();
const { mutateAsync: updateCertTemplate } = useUpdateCertTemplateV2();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: async () => {
if (certTemplate) {
return {
ca: certTemplate.ca,
name: certTemplate.name,
commonName: certTemplate.commonName,
subjectAlternativeName: certTemplate.subjectAlternativeName,
ttl: certTemplate.ttl,
keyUsages: Object.fromEntries(certTemplate.keyUsages.map((name) => [name, true]) ?? []),
extendedKeyUsages: Object.fromEntries(
certTemplate.extendedKeyUsages.map((name) => [name, true]) ?? []
)
};
}
return {
ca: { name: "", id: "" },
name: "",
subjectAlternativeName: "",
commonName: "",
ttl: "",
keyUsages: {
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
[CertKeyUsage.KEY_ENCIPHERMENT]: true
},
extendedKeyUsages: {}
};
}
});
const onFormSubmit = async ({
name,
commonName,
subjectAlternativeName,
ttl,
keyUsages,
extendedKeyUsages,
ca
}: FormData) => {
if (!currentWorkspace?.id) {
return;
}
try {
if (certTemplate) {
await updateCertTemplate({
templateName: certTemplate.name,
projectId: currentWorkspace.id,
caName: ca.name,
name,
commonName,
subjectAlternativeName,
ttl,
keyUsages: Object.entries(keyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertKeyUsage),
extendedKeyUsages: Object.entries(extendedKeyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertExtendedKeyUsage)
});
createNotification({
text: "Successfully updated certificate template",
type: "success"
});
} else {
await createCertTemplate({
projectId: currentWorkspace.id,
caName: ca.name,
name,
commonName,
subjectAlternativeName,
ttl,
keyUsages: Object.entries(keyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertKeyUsage),
extendedKeyUsages: Object.entries(extendedKeyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertExtendedKeyUsage)
});
createNotification({
text: "Successfully created certificate template",
type: "success"
});
}
reset();
handlePopUpToggle(false);
} catch (err) {
console.error(err);
createNotification({
text: "Failed to save changes",
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
{certTemplate && (
<FormControl label="Certificate Template ID">
<Input value={certTemplate.id} isDisabled className="bg-white/[0.07]" />
</FormControl>
)}
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Template Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="my-template" />
</FormControl>
)}
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Issuing CA"
errorText={error?.message}
isError={Boolean(error)}
isRequired
>
<FilterableSelect
options={cas || []}
isLoading={isCaLoading}
placeholder="Select CA..."
onChange={onChange}
value={value}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
control={control}
name="ca"
/>
<Controller
control={control}
defaultValue=""
name="commonName"
render={({ field, fieldState: { error } }) => (
<FormControl
label={
<div>
<FormLabel
isRequired
label="Common Name (CN)"
icon={
<Tooltip
className="text-center"
content={
<span>
This field accepts limited regular expressions: spaces, *, ., @, -, \ (for
escaping), and alphanumeric characters only
</span>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
</Tooltip>
}
/>
</div>
}
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder=".*\.acme.com" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="subjectAlternativeName"
render={({ field, fieldState: { error } }) => (
<FormControl
label={
<div>
<FormLabel
isRequired
label="Alternative Names (SAN)"
icon={
<Tooltip
className="text-center"
content={
<span>
This field accepts limited regular expressions: spaces, *, ., @, -, \ (for
escaping), and alphanumeric characters only
</span>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
</Tooltip>
}
/>
</div>
}
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="service\.acme.\..*" />
</FormControl>
)}
/>
<Controller
control={control}
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Max TTL"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="2 days, 1d, 2h, 1y, ..." />
</FormControl>
)}
/>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="key-usages" className="data-[state=open]:border-none">
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
<div className="order-1 ml-3">Key Usage</div>
</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="keyUsages"
render={({ field: { onChange, value }, fieldState: { error } }) => {
return (
<FormControl
label="Key Usage"
errorText={error?.message}
isError={Boolean(error)}
>
<div className="mb-7 mt-2 grid grid-cols-2 gap-2">
{KEY_USAGES_OPTIONS.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={optionValue}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</div>
</FormControl>
);
}}
/>
<Controller
control={control}
name="extendedKeyUsages"
render={({ field: { onChange, value }, fieldState: { error } }) => {
return (
<FormControl
label="Extended Key Usage"
errorText={error?.message}
isError={Boolean(error)}
>
<div className="mb-7 mt-2 grid grid-cols-2 gap-2">
{EXTENDED_KEY_USAGES_OPTIONS.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={optionValue}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</div>
</FormControl>
);
}}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="mt-4 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Save
</Button>
<Button colorSchema="secondary" variant="plain" onClick={() => handlePopUpToggle(false)}>
Cancel
</Button>
</div>
</form>
);
};
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { PkiTemplateListPage } from "./PkiTemplateListPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/certificate-templates/"
)({
component: PkiTemplateListPage
});
@@ -43,6 +43,11 @@ import {
useSubscription,
useWorkspace
} from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { useGetKmipClientsByProjectId } from "@app/hooks/api/kmip";
@@ -71,7 +76,14 @@ export const KmipClientTable = () => {
perPage,
page,
setPerPage
} = usePagination(KmipClientOrderBy.Name);
} = usePagination(KmipClientOrderBy.Name, {
initPerPage: getUserTablePreference("kmipClientTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("kmipClientTable", PreferenceKey.PerPage, newPerPage);
};
const { data, isPending, isFetching } = useGetKmipClientsByProjectId({
projectId,
@@ -290,7 +302,7 @@ export const KmipClientTable = () => {
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && kmipClients.length === 0 && (
@@ -18,7 +18,7 @@ export const OverviewPage = () => {
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader
title="OverviewPage"
title="Overview Page"
description="Manage keys and perform cryptographic operations."
/>
<ProjectPermissionCan
@@ -53,6 +53,11 @@ import {
useWorkspace
} from "@app/context";
import { kmsKeyUsageOptions } from "@app/helpers/kms";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper, useTimedReset } from "@app/hooks";
import { useGetCmeksByProjectId, useUpdateCmek } from "@app/hooks/api/cmeks";
import { CmekOrderBy, KmsKeyUsage, TCmek } from "@app/hooks/api/cmeks/types";
@@ -100,7 +105,14 @@ export const CmekTable = () => {
perPage,
page,
setPerPage
} = usePagination(CmekOrderBy.Name);
} = usePagination(CmekOrderBy.Name, {
initPerPage: getUserTablePreference("cmekClientTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("cmekClientTable", PreferenceKey.PerPage, newPerPage);
};
const { data, isPending, isFetching } = useGetCmeksByProjectId({
projectId,
@@ -508,7 +520,7 @@ export const CmekTable = () => {
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && keys.length === 0 && (
@@ -34,6 +34,11 @@ import {
Tr
} from "@app/components/v2";
import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetOrganizationGroups, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
@@ -103,7 +108,14 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<GroupsOrderBy>(GroupsOrderBy.Name, { initPerPage: 20 });
} = usePagination<GroupsOrderBy>(GroupsOrderBy.Name, {
initPerPage: getUserTablePreference("orgGroupsTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("orgGroupsTable", PreferenceKey.PerPage, newPerPage);
};
const filteredGroups = useMemo(() => {
const filtered = search
@@ -376,7 +388,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredGroups?.length && (
@@ -138,7 +138,8 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
accessTokenTTL: 2592000,
accessTokenMaxTTL: 2592000,
accessTokenNumUsesLimit: 0
accessTokenNumUsesLimit: 0,
accessTokenPeriod: 0
});
handlePopUpToggle("identity", false);
@@ -42,6 +42,11 @@ import {
Tr
} from "@app/components/v2";
import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetOrgRoles, useSearchIdentities, useUpdateIdentity } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
@@ -76,7 +81,15 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
perPage,
page,
setPerPage
} = usePagination<OrgIdentityOrderBy>(OrgIdentityOrderBy.Name);
} = usePagination<OrgIdentityOrderBy>(OrgIdentityOrderBy.Name, {
initPerPage: getUserTablePreference("identityTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("identityTable", PreferenceKey.PerPage, newPerPage);
};
const [filteredRoles, setFilteredRoles] = useState<string[]>([]);
const organizationId = currentOrg?.id || "";
@@ -379,7 +392,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && data && data?.identities.length === 0 && (
@@ -41,6 +41,13 @@ const schema = z
(value) => Number(value) <= 315360000,
"Access Max Token TTL cannot be greater than 315360000"
),
accessTokenPeriod: z
.string()
.optional()
.refine(
(value) => !value || Number(value) <= 315360000,
"Access Token Period cannot be greater than 315360000"
),
accessTokenNumUsesLimit: z.string(),
clientSecretTrustedIps: z
.object({
@@ -90,7 +97,8 @@ export const IdentityUniversalAuthForm = ({
control,
handleSubmit,
reset,
formState: { isSubmitting }
formState: { isSubmitting },
watch
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
@@ -98,10 +106,13 @@ export const IdentityUniversalAuthForm = ({
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
accessTokenPeriod: "0"
}
});
const accessTokenPeriodValue = Number(watch("accessTokenPeriod"));
const {
fields: clientSecretTrustedIpsFields,
append: appendClientSecretTrustedIp,
@@ -119,6 +130,7 @@ export const IdentityUniversalAuthForm = ({
accessTokenTTL: String(data.accessTokenTTL),
accessTokenMaxTTL: String(data.accessTokenMaxTTL),
accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit),
accessTokenPeriod: String(data.accessTokenPeriod),
clientSecretTrustedIps: data.clientSecretTrustedIps.map(
({ ipAddress, prefix }: IdentityTrustedIp) => {
return {
@@ -139,6 +151,7 @@ export const IdentityUniversalAuthForm = ({
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
accessTokenPeriod: "0",
clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
});
@@ -150,7 +163,8 @@ export const IdentityUniversalAuthForm = ({
accessTokenMaxTTL,
accessTokenNumUsesLimit,
clientSecretTrustedIps,
accessTokenTrustedIps
accessTokenTrustedIps,
accessTokenPeriod
}: FormData) => {
try {
if (!identityId) return;
@@ -164,7 +178,8 @@ export const IdentityUniversalAuthForm = ({
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
accessTokenTrustedIps,
accessTokenPeriod: Number(accessTokenPeriod)
});
} else {
// create new universal auth configuration
@@ -176,7 +191,8 @@ export const IdentityUniversalAuthForm = ({
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
accessTokenTrustedIps,
accessTokenPeriod: Number(accessTokenPeriod)
});
}
@@ -214,34 +230,42 @@ export const IdentityUniversalAuthForm = ({
<Tab value={IdentityFormTab.Advanced}>Advanced</Tab>
</TabList>
<TabPanel value={IdentityFormTab.Configuration}>
<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="0" 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="0" step="1" />
</FormControl>
)}
/>
{accessTokenPeriodValue > 0 ? (
<div className="mb-4 text-xs text-bunker-400">
When Access Token Period is set, TTL and Max TTL are ignored.
</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="0" 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="0" step="1" />
</FormControl>
)}
/>
</>
)}
<Controller
control={control}
defaultValue="0"
@@ -256,6 +280,21 @@ export const IdentityUniversalAuthForm = ({
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="0"
name="accessTokenPeriod"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Period (seconds)"
isError={Boolean(error)}
errorText={error?.message}
helperText="For periodic tokens: set a period (in seconds) to allow indefinite renewal. Set to 0 to disable periodic tokens and use TTL-based expiration."
>
<Input {...field} placeholder="0" type="number" min="0" step="1" />
</FormControl>
)}
/>
</TabPanel>
<TabPanel value={IdentityFormTab.Advanced}>
{clientSecretTrustedIpsFields.map(({ id }, index) => (
@@ -42,6 +42,11 @@ import {
useSubscription,
useUser
} from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import {
useFetchServerStatus,
@@ -170,7 +175,14 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<OrgMembersOrderBy>(OrgMembersOrderBy.Name, { initPerPage: 20 });
} = usePagination<OrgMembersOrderBy>(OrgMembersOrderBy.Name, {
initPerPage: getUserTablePreference("orgMembersTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("orgMembersTable", PreferenceKey.PerPage, newPerPage);
};
const filteredUsers = useMemo(
() =>
@@ -513,7 +525,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isMembersLoading && !filteredUsers?.length && (
@@ -4,7 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, Checkbox, Modal, ModalContent } from "@app/components/v2";
import { Button, Checkbox, Modal, ModalContent, Tooltip } from "@app/components/v2";
import { useOrgPermission } from "@app/context";
import { useUpgradePrivilegeSystem } from "@app/hooks/api";
@@ -262,7 +262,6 @@ export const UpgradePrivilegeSystemModal = ({ isOpen, onOpenChange }: Props) =>
</div>
</div>
</div>
<form onSubmit={handleSubmit(handlePrivilegeSystemUpgrade)}>
<div className="mt-6 flex items-center justify-end gap-4">
<button
@@ -272,17 +271,27 @@ export const UpgradePrivilegeSystemModal = ({ isOpen, onOpenChange }: Props) =>
>
Cancel
</button>
<Button
type="submit"
variant="solid"
colorSchema="primary"
size="md"
className="w-[120px] bg-primary hover:bg-primary-600"
isDisabled={!isAllChecksCompleted || !isAdmin}
isLoading={isSubmitting}
<Tooltip
content={
!isAdmin
? `You cannot perform this upgrade because you are not an organization admin. (Your current role: ${membership?.role ?? "Unknown"})`
: undefined
}
>
Upgrade
</Button>
<div>
<Button
type="submit"
variant="solid"
colorSchema="primary"
size="md"
className="w-[120px] bg-primary hover:bg-primary-600"
isDisabled={!isAllChecksCompleted || !isAdmin}
isLoading={isSubmitting}
>
Upgrade
</Button>
</div>
</Tooltip>
</div>
</form>
</div>
@@ -0,0 +1,150 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FormControl,
Input,
ModalClose,
SecretInput,
Select,
SelectItem
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { OnePassConnectionMethod, TOnePassConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TOnePassConnection;
onSubmit: (formData: FormData) => void;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.OnePass)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(OnePassConnectionMethod.ApiToken),
credentials: z.object({
apiToken: z.string().trim().min(1, "API Token required"),
instanceUrl: z.string().trim().url("Invalid Connect Server instance URL")
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const OnePassConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.OnePass,
method: OnePassConnectionMethod.ApiToken
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.OnePass].name
}. This field cannot be changed after creation.`}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(OnePassConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.instanceUrl"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Instance URL"
tooltipClassName="max-w-sm"
tooltipText="The URL of the 1Password Connect Server instance to authenticate with."
>
<Input {...field} placeholder="https://1pass.example.com" />
</FormControl>
)}
/>
<Controller
name="credentials.apiToken"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="API Token"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to 1Password"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -9,6 +9,7 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { DiscriminativePick } from "@app/types";
import { AppConnectionHeader } from "../AppConnectionHeader";
import { OnePassConnectionForm } from "./1PasswordConnectionForm";
import { Auth0ConnectionForm } from "./Auth0ConnectionForm";
import { AwsConnectionForm } from "./AwsConnectionForm";
import { AzureAppConfigurationConnectionForm } from "./AzureAppConfigurationConnectionForm";
@@ -23,6 +24,7 @@ import { HCVaultConnectionForm } from "./HCVaultConnectionForm";
import { HumanitecConnectionForm } from "./HumanitecConnectionForm";
import { LdapConnectionForm } from "./LdapConnectionForm";
import { MsSqlConnectionForm } from "./MsSqlConnectionForm";
import { MySqlConnectionForm } from "./MySqlConnectionForm";
import { OCIConnectionForm } from "./OCIConnectionForm";
import { PostgresConnectionForm } from "./PostgresConnectionForm";
import { TeamCityConnectionForm } from "./TeamCityConnectionForm";
@@ -91,6 +93,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
return <PostgresConnectionForm onSubmit={onSubmit} />;
case AppConnection.MsSql:
return <MsSqlConnectionForm onSubmit={onSubmit} />;
case AppConnection.MySql:
return <MySqlConnectionForm onSubmit={onSubmit} />;
case AppConnection.Camunda:
return <CamundaConnectionForm onSubmit={onSubmit} />;
case AppConnection.AzureClientSecrets:
@@ -107,6 +111,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
return <TeamCityConnectionForm onSubmit={onSubmit} />;
case AppConnection.OCI:
return <OCIConnectionForm onSubmit={onSubmit} />;
case AppConnection.OnePass:
return <OnePassConnectionForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled App ${app}`);
}
@@ -167,6 +173,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
return <PostgresConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.MsSql:
return <MsSqlConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.MySql:
return <MySqlConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Camunda:
return <CamundaConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.AzureClientSecrets:
@@ -183,6 +191,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
return <TeamCityConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.OCI:
return <OCIConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.OnePass:
return <OnePassConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
default:
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
}
@@ -5,7 +5,7 @@ import { FormControl, Input, TextArea } from "@app/components/v2";
import { slugSchema } from "@app/lib/schemas";
export const genericAppConnectionFieldsSchema = z.object({
name: slugSchema({ min: 1, max: 32, field: "Name" }),
name: slugSchema({ min: 1, max: 64, field: "Name" }),
description: z.string().trim().max(256, "Description cannot exceed 256 characters").nullish()
});
@@ -19,7 +19,7 @@ import {
Tooltip
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { DistinguishedNameRegex } from "@app/helpers/string";
import { DistinguishedNameRegex, UserPrincipalNameRegex } from "@app/helpers/string";
import {
LdapConnectionMethod,
LdapConnectionProvider,
@@ -55,8 +55,13 @@ const formSchema = z.discriminatedUnion("method", [
dn: z
.string()
.trim()
.regex(DistinguishedNameRegex, "Invalid Distinguished Name format")
.min(1, "Distinguished Name (DN) required"),
.min(1, "DN/UPN required")
.refine(
(value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value),
{
message: "Invalid DN/UPN format"
}
),
password: z.string().trim().min(1, "Password required"),
sslRejectUnauthorized: z.boolean(),
sslCertificate: z
@@ -223,7 +228,7 @@ export const LdapConnectionForm = ({ appConnection, onSubmit }: Props) => {
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Binding Distinguished Name (DN)"
label="Binding DN/UPN"
>
<Input {...field} placeholder="CN=John,OU=Users,DC=example,DC=com" />
</FormControl>
@@ -0,0 +1,155 @@
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, ModalClose, Select, SelectItem } from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { MySqlConnectionMethod, TMySqlConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { PlatformManagedConfirmationModal } from "@app/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/PlatformManagedConfirmationModal";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
import {
BaseSqlUsernameAndPasswordConnectionSchema,
PlatformManagedNoticeBanner,
SqlConnectionFields
} from "./shared";
type Props = {
appConnection?: TMySqlConnection;
onSubmit: (formData: FormData) => Promise<void>;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.MySql),
isPlatformManagedCredentials: z.boolean().optional()
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(MySqlConnectionMethod.UsernameAndPassword),
credentials: BaseSqlUsernameAndPasswordConnectionSchema
})
]);
type FormData = z.infer<typeof formSchema>;
export const MySqlConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const [showConfirmation, setShowConfirmation] = useState(false);
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.MySql,
method: MySqlConnectionMethod.UsernameAndPassword,
credentials: {
host: "",
port: 3306,
database: "default",
username: "",
password: "",
sslEnabled: true,
sslRejectUnauthorized: true,
sslCertificate: undefined
}
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false;
const confirmSubmit = async (formData: FormData) => {
if (formData.isPlatformManagedCredentials) {
setShowConfirmation(true);
return;
}
await onSubmit(formData);
};
return (
<FormProvider {...form}>
<form
onSubmit={(e) => {
setSelectedTabIndex(0);
handleSubmit(confirmSubmit)(e);
}}
>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.MySql].name
}. This field cannot be changed after creation.`}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(MySqlConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<SqlConnectionFields
isPlatformManagedCredentials={isPlatformManagedCredentials}
selectedTabIndex={selectedTabIndex}
setSelectedTabIndex={setSelectedTabIndex}
/>
{isPlatformManagedCredentials ? (
<PlatformManagedNoticeBanner />
) : (
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to Database"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
)}
</form>
<PlatformManagedConfirmationModal
onConfirm={() => handleSubmit(onSubmit)()}
onOpenChange={setShowConfirmation}
isOpen={showConfirmation}
/>
</FormProvider>
);
};
@@ -1,8 +1,11 @@
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { faWrench } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { Spinner, Tooltip } from "@app/components/v2";
import { useSubscription } from "@app/context";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { usePopUp } from "@app/hooks";
import { useAppConnectionOptions } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
@@ -11,8 +14,11 @@ type Props = {
};
export const AppConnectionsSelect = ({ onSelect }: Props) => {
const { subscription } = useSubscription();
const { isPending, data: appConnectionOptions } = useAppConnectionOptions();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
if (isPending) {
return (
<div className="flex h-full flex-col items-center justify-center py-2.5">
@@ -23,81 +29,86 @@ export const AppConnectionsSelect = ({ onSelect }: Props) => {
}
return (
<>
<div className="grid grid-cols-4 gap-2">
{appConnectionOptions?.map((option) => {
const { image, name, size = 50, icon } = APP_CONNECTION_MAP[option.app];
<div className="grid grid-cols-4 gap-2">
{appConnectionOptions?.map((option) => {
const { image, name, size = 50, enterprise = false, icon } = APP_CONNECTION_MAP[option.app];
return (
<button
type="button"
key={option.app}
onClick={() => onSelect(option.app)}
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
>
<div className="relative">
<img
src={`/images/integrations/${image}`}
style={{
width: `${size}px`
}}
className="mt-auto"
alt={`${name} logo`}
return (
<button
type="button"
onClick={() =>
enterprise && !subscription.enterpriseAppConnections
? handlePopUpOpen("upgradePlan")
: onSelect(option.app)
}
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
>
<div className="relative">
<img
src={`/images/integrations/${image}`}
style={{
width: `${size}px`
}}
className="mt-auto"
alt={`${name} logo`}
/>
{icon && (
<FontAwesomeIcon
className="-right- absolute -bottom-1.5 -right-1.5 text-primary-700"
size="xl"
icon={icon}
/>
{icon && (
<FontAwesomeIcon
className="-right- absolute -bottom-1.5 -right-1.5 text-primary-700"
size="xl"
icon={icon}
/>
)}
</div>
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
{name}
</div>
</button>
);
})}
</div>
<div className="mt-2 flex items-center gap-1">
<div className="max-w-xs text-center text-sm font-medium text-mineshaft-400 duration-200">
Don&#39;t see the connection you&#39;re looking for?
</div>
<Tooltip
side="bottom"
className="max-w-sm py-4"
content={
<>
<p className="mb-2">Infisical is constantly adding support for more connections.</p>
<p>
{`If you don't see the third-party
)}
</div>
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
{name}
</div>
</button>
);
})}
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use every App Connection if you switch to Infisical's Enterprise plan."
/>
<Tooltip
side="bottom"
className="max-w-sm py-4"
content={
<>
<p className="mb-2">Infisical is constantly adding support for more connections.</p>
<p>
{`If you don't see the third-party
app you're looking for,`}{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://infisical.com/slack"
rel="noopener noreferrer"
>
let us know on Slack
</a>{" "}
or{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://github.com/Infisical/infisical/discussions"
rel="noopener noreferrer"
>
make a request on GitHub
</a>
.
</p>
</>
}
>
<FontAwesomeIcon className="text-mineshaft-400" size="xs" icon={faInfoCircle} />
</Tooltip>
</div>
</>
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://infisical.com/slack"
rel="noopener noreferrer"
>
let us know on Slack
</a>{" "}
or{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://github.com/Infisical/infisical/discussions"
rel="noopener noreferrer"
>
make a request on GitHub
</a>
.
</p>
</>
}
>
<div className="group relative flex h-28 flex-col items-center justify-center rounded-md border border-dashed border-mineshaft-600 bg-mineshaft-800 p-4">
<FontAwesomeIcon className="mt-auto text-xl" icon={faWrench} />
<div className="mt-auto max-w-xs text-center text-sm font-medium text-gray-300 duration-200 group-hover:text-gray-200">
Coming Soon
</div>
</div>
</Tooltip>
</div>
);
};
@@ -30,6 +30,11 @@ import {
Tr
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { TAppConnection, useListAppConnections } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
@@ -76,7 +81,14 @@ export const AppConnectionsTable = () => {
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<AppConnectionsOrderBy>(AppConnectionsOrderBy.App, { initPerPage: 20 });
} = usePagination<AppConnectionsOrderBy>(AppConnectionsOrderBy.App, {
initPerPage: getUserTablePreference("appConnectionsTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("appConnectionsTable", PreferenceKey.PerPage, newPerPage);
};
const filteredAppConnections = useMemo(
() =>
@@ -282,7 +294,7 @@ export const AppConnectionsTable = () => {
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredAppConnections?.length && (
@@ -2,7 +2,7 @@ import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { OrgPermissionCan } from "@app/components/permissions";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { OrgPermissionBillingActions, OrgPermissionSubjects } from "@app/context";
import { BillingTabGroup } from "./components";
@@ -24,7 +24,7 @@ export const BillingPage = () => {
</div>
<OrgPermissionCan
passThrough={false}
I={OrgPermissionActions.Read}
I={OrgPermissionBillingActions.Read}
a={OrgPermissionSubjects.Billing}
>
<BillingTabGroup />
@@ -1,4 +1,9 @@
import { faCircleCheck, faCircleXmark, faFileInvoice } from "@fortawesome/free-solid-svg-icons";
import {
faCircleCheck,
faCircleXmark,
faFileInvoice,
faInfoCircle
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
@@ -10,6 +15,7 @@ import {
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
@@ -48,9 +54,26 @@ export const CurrentPlanSection = () => {
data &&
data?.rows?.length > 0 &&
data.rows.map(({ name, allowed, used }) => {
let toolTipText = null;
if (name === "Organization identity limit") {
toolTipText =
"Identity count is calculated by the total number of user identities and machine identities.";
}
return (
<Tr key={`current-plan-row-${name}`} className="h-12">
<Td>{name}</Td>
<Td>
{name}
{toolTipText && (
<Tooltip content={toolTipText}>
<FontAwesomeIcon
icon={faInfoCircle}
className="relative bottom-2 left-2"
size="xs"
/>
</Tooltip>
)}
</Td>
<Td>{displayCell(allowed)}</Td>
<Td>{used}</Td>
</Tr>
@@ -4,7 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionBillingActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
@@ -112,7 +112,10 @@ export const PreviewSection = () => {
Get unlimited members, projects, RBAC, smart alerts, and so much more.
</p>
</div>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Billing}>
<OrgPermissionCan
I={OrgPermissionBillingActions.ManageBilling}
a={OrgPermissionSubjects.Billing}
>
{(isAllowed) => (
<Button
onClick={() => handleUpgradeBtnClick()}
@@ -156,7 +159,10 @@ export const PreviewSection = () => {
}`}
</p>
{isInfisicalCloud() && (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Billing}>
<OrgPermissionCan
I={OrgPermissionBillingActions.ManageBilling}
a={OrgPermissionSubjects.Billing}
>
{(isAllowed) => (
<button
type="button"
@@ -6,7 +6,7 @@ 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 { OrgPermissionBillingActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useGetOrgBillingDetails, useUpdateOrgBillingDetails } from "@app/hooks/api";
const schema = z
@@ -74,7 +74,10 @@ export const CompanyNameSection = () => {
name="name"
/>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Billing}>
<OrgPermissionCan
I={OrgPermissionBillingActions.ManageBilling}
a={OrgPermissionSubjects.Billing}
>
{(isAllowed) => (
<Button
type="submit"
@@ -6,7 +6,7 @@ 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 { OrgPermissionBillingActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useGetOrgBillingDetails, useUpdateOrgBillingDetails } from "@app/hooks/api";
const schema = z
@@ -75,7 +75,10 @@ export const InvoiceEmailSection = () => {
name="email"
/>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Billing}>
<OrgPermissionCan
I={OrgPermissionBillingActions.ManageBilling}
a={OrgPermissionSubjects.Billing}
>
{(isAllowed) => (
<Button
type="submit"
@@ -3,7 +3,7 @@ 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 { OrgPermissionBillingActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useAddOrgPmtMethod } from "@app/hooks/api";
import { PmtMethodsTable } from "./PmtMethodsTable";
@@ -27,7 +27,10 @@ export const PmtMethodsSection = () => {
<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}>
<OrgPermissionCan
I={OrgPermissionBillingActions.ManageBilling}
a={OrgPermissionSubjects.Billing}
>
{(isAllowed) => (
<Button
onClick={handleAddPmtMethodBtnClick}
@@ -16,7 +16,7 @@ import {
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { OrgPermissionBillingActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteOrgPmtMethod, useGetOrgPmtMethods } from "@app/hooks/api";
@@ -75,7 +75,7 @@ export const PmtMethodsTable = () => {
<Td>{`${exp_month}/${exp_year}`}</Td>
<Td>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
I={OrgPermissionBillingActions.ManageBilling}
a={OrgPermissionSubjects.Billing}
>
{(isAllowed) => (
@@ -3,7 +3,7 @@ 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 { OrgPermissionBillingActions, OrgPermissionSubjects } from "@app/context";
import { usePopUp } from "@app/hooks/usePopUp";
import { TaxIDModal } from "./TaxIDModal";
@@ -18,7 +18,10 @@ export const TaxIDSection = () => {
<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}>
<OrgPermissionCan
I={OrgPermissionBillingActions.ManageBilling}
a={OrgPermissionSubjects.Billing}
>
{(isAllowed) => (
<Button
onClick={() => handlePopUpOpen("addTaxID")}
@@ -14,7 +14,7 @@ import {
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { OrgPermissionBillingActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useDeleteOrgTaxId, useGetOrgTaxIds } from "@app/hooks/api";
const taxIDTypeLabelMap: { [key: string]: string } = {
@@ -103,7 +103,7 @@ export const TaxIDTable = () => {
<Td>{value}</Td>
<Td>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
I={OrgPermissionBillingActions.ManageBilling}
a={OrgPermissionSubjects.Billing}
>
{(isAllowed) => (
@@ -1,5 +1,5 @@
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { OrgPermissionBillingActions, OrgPermissionSubjects } from "@app/context";
import { isInfisicalCloud } from "@app/helpers/platform";
import { withPermission } from "@app/hoc";
@@ -47,5 +47,5 @@ export const BillingTabGroup = withPermission(
</Tabs>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Billing }
{ action: OrgPermissionBillingActions.Read, subject: OrgPermissionSubjects.Billing }
);
@@ -25,6 +25,11 @@ import {
Tr
} from "@app/components/v2";
import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useListGroupUsers, useOidcManageGroupMembershipsEnabled } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
@@ -57,7 +62,14 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props
offset,
orderDirection,
toggleOrderDirection
} = usePagination(GroupMembersOrderBy.Name, { initPerPage: 10 });
} = usePagination(GroupMembersOrderBy.Name, {
initPerPage: getUserTablePreference("groupMembersTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("groupMembersTable", PreferenceKey.PerPage, newPerPage);
};
const { currentOrg } = useOrganization();
@@ -163,7 +175,7 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredGroupMemberships?.length && (
@@ -21,6 +21,11 @@ import {
THead,
Tr
} from "@app/components/v2";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetIdentityProjectMemberships } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
@@ -53,7 +58,14 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) =>
offset,
orderDirection,
toggleOrderDirection
} = usePagination(IdentityProjectsOrderBy.Name, { initPerPage: 10 });
} = usePagination(IdentityProjectsOrderBy.Name, {
initPerPage: getUserTablePreference("identityProjectsTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("identityProjectsTable", PreferenceKey.PerPage, newPerPage);
};
const filteredProjectMemberships = useMemo(
() =>
@@ -132,7 +144,7 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) =>
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredProjectMemberships?.length && (
@@ -62,12 +62,20 @@ export const ViewIdentityUniversalAuthContent = ({
onEdit={() => handlePopUpOpen("identityAuthMethod")}
onDelete={onDelete}
>
<IdentityAuthFieldDisplay label="Access Token TTL (seconds)">
{data.accessTokenTTL}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Access Token Max TTL (seconds)">
{data.accessTokenMaxTTL}
</IdentityAuthFieldDisplay>
{Number(data.accessTokenPeriod) > 0 ? (
<IdentityAuthFieldDisplay label="Access Token Period (seconds)">
{data.accessTokenPeriod}
</IdentityAuthFieldDisplay>
) : (
<>
<IdentityAuthFieldDisplay label="Access Token TTL (seconds)">
{data.accessTokenTTL}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Access Token Max TTL (seconds)">
{data.accessTokenMaxTTL}
</IdentityAuthFieldDisplay>
</>
)}
<IdentityAuthFieldDisplay label="Access Token Max Number of Uses">
{data.accessTokenNumUsesLimit}
</IdentityAuthFieldDisplay>
@@ -5,6 +5,7 @@ import { OrgPermissionSubjects } from "@app/context";
import {
OrgGatewayPermissionActions,
OrgPermissionAppConnectionActions,
OrgPermissionBillingActions,
OrgPermissionGroupActions,
OrgPermissionIdentityActions,
OrgPermissionKmipActions,
@@ -21,6 +22,13 @@ const generalPermissionSchema = z
})
.optional();
const billingPermissionSchema = z
.object({
[OrgPermissionBillingActions.Read]: z.boolean().optional(),
[OrgPermissionBillingActions.ManageBilling]: z.boolean().optional()
})
.optional();
const appConnectionsPermissionSchema = z
.object({
[OrgPermissionAppConnectionActions.Read]: z.boolean().optional(),
@@ -113,7 +121,7 @@ export const formSchema = z.object({
scim: generalPermissionSchema,
[OrgPermissionSubjects.GithubOrgSync]: generalPermissionSchema,
ldap: generalPermissionSchema,
billing: generalPermissionSchema,
billing: billingPermissionSchema,
identity: identityPermissionSchema,
"organization-admin-console": adminConsolePermissionSchmea,
[OrgPermissionSubjects.Kms]: generalPermissionSchema,
@@ -0,0 +1,174 @@
import { useEffect, useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2";
import { OrgPermissionBillingActions } from "@app/context/OrgPermissionContext/types";
import { useToggle } from "@app/hooks";
import { TFormSchema } from "../OrgRoleModifySection.utils";
const PERMISSION_ACTIONS = [
{ action: OrgPermissionBillingActions.Read, label: "View bills" },
{ action: OrgPermissionBillingActions.ManageBilling, label: "Manage billing" }
] as const;
type Props = {
isEditable: boolean;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
};
enum Permission {
NoAccess = "no-access",
ReadOnly = "read-only",
FullAccess = "full-access",
Custom = "custom"
}
export const OrgPermissionBillingRow = ({ isEditable, control, setValue }: Props) => {
const [isRowExpanded, setIsRowExpanded] = useToggle();
const [isCustom, setIsCustom] = useToggle();
const rule = useWatch({
control,
name: "permissions.billing"
});
const selectedPermissionCategory = useMemo(() => {
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
const totalActions = PERMISSION_ACTIONS.length;
const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
if (isCustom) return Permission.Custom;
if (score === 0) return Permission.NoAccess;
if (score === totalActions) return Permission.FullAccess;
if (score === 1 && rule?.[OrgPermissionBillingActions.Read]) return Permission.ReadOnly;
return Permission.Custom;
}, [rule, isCustom]);
useEffect(() => {
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
}, [selectedPermissionCategory]);
const handlePermissionChange = (val: Permission) => {
if (val === Permission.Custom) {
setIsRowExpanded.on();
setIsCustom.on();
return;
}
setIsCustom.off();
switch (val) {
case Permission.NoAccess:
setValue(
"permissions.billing",
{
[OrgPermissionBillingActions.Read]: false,
[OrgPermissionBillingActions.ManageBilling]: false
},
{ shouldDirty: true }
);
break;
case Permission.ReadOnly:
setValue(
"permissions.billing",
{
[OrgPermissionBillingActions.Read]: true,
[OrgPermissionBillingActions.ManageBilling]: false
},
{ shouldDirty: true }
);
break;
case Permission.FullAccess:
setValue(
"permissions.billing",
{
[OrgPermissionBillingActions.Read]: true,
[OrgPermissionBillingActions.ManageBilling]: true
},
{ shouldDirty: true }
);
break;
default:
setValue(
"permissions.billing",
{
[OrgPermissionBillingActions.Read]: false,
[OrgPermissionBillingActions.ManageBilling]: false
},
{ shouldDirty: true }
);
break;
}
};
return (
<>
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
onClick={() => setIsRowExpanded.toggle()}
>
<Td>
<FontAwesomeIcon icon={isRowExpanded ? faChevronDown : faChevronRight} />
</Td>
<Td>Billing</Td>
<Td>
<Select
value={selectedPermissionCategory}
className="w-40 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={handlePermissionChange}
isDisabled={!isEditable}
>
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
<SelectItem value={Permission.Custom}>Custom</SelectItem>
</Select>
</Td>
</Tr>
{isRowExpanded && (
<Tr>
<Td
colSpan={3}
className={`bg-bunker-600 px-0 py-0 ${isRowExpanded && "border-mineshaft-500 p-8"}`}
>
<div className="grid grid-cols-3 gap-4">
{PERMISSION_ACTIONS.map(({ action, label }) => {
return (
<Controller
name={`permissions.billing.${action}`}
key={`permissions.billing.${action}`}
control={control}
render={({ field }) => (
<Checkbox
isChecked={field.value}
onCheckedChange={(e) => {
if (!isEditable) {
createNotification({
type: "error",
text: "Failed to update default role"
});
return;
}
field.onChange(e);
}}
id={`permissions.billing.${action}`}
>
{label}
</Checkbox>
)}
/>
);
})}
</div>
</Td>
</Tr>
)}
</>
);
};
@@ -38,13 +38,6 @@ const MEMBERS_PERMISSIONS = [
{ action: "delete", label: "Remove members" }
] as const;
const BILLING_PERMISSIONS = [
{ action: "read", label: "View bills" },
{ action: "create", label: "Add payment methods" },
{ action: "edit", label: "Edit payments" },
{ action: "delete", label: "Remove payments" }
] as const;
const PROJECT_TEMPLATES_PERMISSIONS = [
{ action: "read", label: "View & Apply" },
{ action: "create", label: "Create" },
@@ -52,18 +45,16 @@ const PROJECT_TEMPLATES_PERMISSIONS = [
{ action: "delete", label: "Remove" }
] as const;
const getPermissionList = (option: string) => {
switch (option) {
case "secret-scanning":
return SECRET_SCANNING_PERMISSIONS;
case "billing":
return BILLING_PERMISSIONS;
case "incident-contact":
return INCIDENT_CONTACTS_PERMISSIONS;
const getPermissionList = (formName: Props["formName"]) => {
switch (formName) {
case "member":
return MEMBERS_PERMISSIONS;
case OrgPermissionSubjects.ProjectTemplates:
return PROJECT_TEMPLATES_PERMISSIONS;
case "secret-scanning":
return SECRET_SCANNING_PERMISSIONS;
case "incident-contact":
return INCIDENT_CONTACTS_PERMISSIONS;
default:
return PERMISSIONS;
}
@@ -74,7 +65,7 @@ type Props = {
title: string;
formName: keyof Omit<
Exclude<TFormSchema["permissions"], undefined>,
"workspace" | "organization-admin-console" | "kmip" | "gateway" | "secret-share"
"workspace" | "organization-admin-console" | "kmip" | "gateway" | "secret-share" | "billing"
>;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
@@ -14,6 +14,7 @@ import {
TFormSchema
} from "../OrgRoleModifySection.utils";
import { OrgPermissionAdminConsoleRow } from "./OrgPermissionAdminConsoleRow";
import { OrgPermissionBillingRow } from "./OrgPermissionBillingRow";
import { OrgGatewayPermissionRow } from "./OrgPermissionGatewayRow";
import { OrgPermissionGroupRow } from "./OrgPermissionGroupRow";
import { OrgPermissionIdentityRow } from "./OrgPermissionIdentityRow";
@@ -27,10 +28,6 @@ const SIMPLE_PERMISSION_OPTIONS = [
title: "User Management",
formName: "member"
},
{
title: "Usage & Billing",
formName: "billing"
},
{
title: "Role Management",
formName: "role"
@@ -186,6 +183,11 @@ export const RolePermissionsSection = ({ roleId }: Props) => {
setValue={setValue}
isEditable={isCustomRole}
/>
<OrgPermissionBillingRow
control={control}
setValue={setValue}
isEditable={isCustomRole}
/>
<OrgPermissionSecretShareRow
control={control}
setValue={setValue}
@@ -28,6 +28,11 @@ import {
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { getProjectHomePage } from "@app/helpers/project";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { useRequestProjectAccess, useSearchProjects } from "@app/hooks/api";
import { ProjectType, Workspace } from "@app/hooks/api/workspace/types";
@@ -104,7 +109,15 @@ export const AllProjectView = ({
limit,
toggleOrderDirection,
orderDirection
} = usePagination("name", { initPerPage: 50 });
} = usePagination("name", {
initPerPage: getUserTablePreference("allProjectsTable", PreferenceKey.PerPage, 50)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("allProjectsTable", PreferenceKey.PerPage, newPerPage);
};
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp([
"requestAccessConfirmation"
] as const);
@@ -274,7 +287,7 @@ export const AllProjectView = ({
count={searchedProjects?.totalCount || 0}
page={page}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isProjectLoading && !searchedProjects?.totalCount && (
@@ -19,6 +19,11 @@ import { OrgPermissionCan } from "@app/components/permissions";
import { Button, IconButton, Input, Pagination, Skeleton, Tooltip } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { getProjectHomePage } from "@app/helpers/project";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetUserWorkspaces } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
@@ -63,7 +68,15 @@ export const MyProjectView = ({
limit,
toggleOrderDirection,
orderDirection
} = usePagination(ProjectOrderBy.Name, { initPerPage: 24 });
} = usePagination(ProjectOrderBy.Name, {
initPerPage: getUserTablePreference("myProjectsTable", PreferenceKey.PerPage, 24)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("myProjectsTable", PreferenceKey.PerPage, newPerPage);
};
const { data: projectFavorites, isPending: isProjectFavoritesLoading } =
useGetUserProjectFavorites(currentOrg?.id);
@@ -415,7 +428,7 @@ export const MyProjectView = ({
count={filteredWorkspaces.length}
page={page}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{isWorkspaceEmpty && (
@@ -30,6 +30,8 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
allowSecretSharingOutsideOrganization={
currentOrg?.allowSecretSharingOutsideOrganization ?? true
}
maxSharedSecretLifetime={currentOrg?.maxSharedSecretLifetime}
maxSharedSecretViewLimit={currentOrg?.maxSharedSecretViewLimit}
/>
</ModalContent>
</Modal>
@@ -17,11 +17,11 @@ export const SecretSharingSettingsPage = withPermission(
return (
<>
<Helmet>
<title>{t("common.head-title", { title: t("settings.org.title") })}</title>
<title>{t("common.head-title", { title: "Secret Share Settings" })}</title>
</Helmet>
<div className="flex w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl">
<PageHeader title={t("settings.org.title")} />
<PageHeader title="Secret Share Settings" />
<SecretSharingSettingsTabGroup />
</div>
</div>
@@ -0,0 +1,294 @@
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, Select, SelectItem } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useUpdateOrg } from "@app/hooks/api";
const MAX_SHARED_SECRET_LIFETIME_SECONDS = 30 * 24 * 60 * 60; // 30 days in seconds
const MIN_SHARED_SECRET_LIFETIME_SECONDS = 5 * 60; // 5 minutes in seconds
// Helper function to convert duration to seconds
const durationToSeconds = (value: number, unit: "m" | "h" | "d"): number => {
switch (unit) {
case "m":
return value * 60;
case "h":
return value * 60 * 60;
case "d":
return value * 60 * 60 * 24;
default:
return 0;
}
};
// Helper function to convert seconds to form lifetime value and unit
const getFormLifetimeFromSeconds = (
totalSeconds: number | null | undefined
): { maxLifetimeValue: number; maxLifetimeUnit: "m" | "h" | "d" } => {
const DEFAULT_LIFETIME_VALUE = 30;
const DEFAULT_LIFETIME_UNIT = "d" as "m" | "h" | "d";
if (totalSeconds == null || totalSeconds <= 0) {
return {
maxLifetimeValue: DEFAULT_LIFETIME_VALUE,
maxLifetimeUnit: DEFAULT_LIFETIME_UNIT
};
}
const secondsInDay = 24 * 60 * 60;
const secondsInHour = 60 * 60;
const secondsInMinute = 60;
if (totalSeconds % secondsInDay === 0) {
const value = totalSeconds / secondsInDay;
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "d" };
}
if (totalSeconds % secondsInHour === 0) {
const value = totalSeconds / secondsInHour;
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "h" };
}
if (totalSeconds % secondsInMinute === 0) {
const value = totalSeconds / secondsInMinute;
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "m" };
}
return {
maxLifetimeValue: DEFAULT_LIFETIME_VALUE,
maxLifetimeUnit: DEFAULT_LIFETIME_UNIT
};
};
const formSchema = z
.object({
maxLifetimeValue: z.number().min(1, "Value must be at least 1"),
maxLifetimeUnit: z.enum(["m", "h", "d"], {
invalid_type_error: "Please select a valid time unit"
}),
maxViewLimit: z.string()
})
.superRefine((data, ctx) => {
const { maxLifetimeValue, maxLifetimeUnit } = data;
const durationInSeconds = durationToSeconds(maxLifetimeValue, maxLifetimeUnit);
// Check max limit
if (durationInSeconds > MAX_SHARED_SECRET_LIFETIME_SECONDS) {
let message = "Duration exceeds maximum allowed limit";
if (maxLifetimeUnit === "m") {
message = `Maximum allowed minutes is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / 60} (30 days)`;
} else if (maxLifetimeUnit === "h") {
message = `Maximum allowed hours is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / (60 * 60)} (30 days)`;
} else if (maxLifetimeUnit === "d") {
message = `Maximum allowed days is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / (24 * 60 * 60)}`;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message,
path: ["maxLifetimeValue"]
});
}
// Check min limit
if (durationInSeconds < MIN_SHARED_SECRET_LIFETIME_SECONDS) {
const message = `Duration must be at least ${MIN_SHARED_SECRET_LIFETIME_SECONDS / 60} minutes`; // 5 minutes
ctx.addIssue({
code: z.ZodIssueCode.custom,
message,
path: ["maxLifetimeValue"]
});
}
});
type TForm = z.infer<typeof formSchema>;
const viewLimitOptions = [
{ label: "1", value: 1 },
{ label: "Unlimited", value: -1 }
];
export const OrgSecretShareLimitSection = () => {
const { mutateAsync } = useUpdateOrg();
const { currentOrg } = useOrganization();
const getDefaultFormValues = () => {
const initialLifetime = getFormLifetimeFromSeconds(currentOrg?.maxSharedSecretLifetime);
return {
maxLifetimeValue: initialLifetime.maxLifetimeValue,
maxLifetimeUnit: initialLifetime.maxLifetimeUnit,
maxViewLimit: currentOrg?.maxSharedSecretViewLimit?.toString() || "-1"
};
};
const {
control,
formState: { isSubmitting, isDirty },
handleSubmit,
reset
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: getDefaultFormValues()
});
useEffect(() => {
if (currentOrg) {
reset(getDefaultFormValues());
}
}, [currentOrg, reset]);
const handleFormSubmit = async (formData: TForm) => {
try {
const maxSharedSecretLifetimeSeconds = durationToSeconds(
formData.maxLifetimeValue,
formData.maxLifetimeUnit
);
await mutateAsync({
orgId: currentOrg.id,
maxSharedSecretViewLimit:
formData.maxViewLimit === "-1" ? null : Number(formData.maxViewLimit),
maxSharedSecretLifetime: maxSharedSecretLifetimeSeconds
});
createNotification({
text: "Successfully updated secret share limits",
type: "success"
});
reset(formData);
} catch {
createNotification({
text: "Failed to update secret share limits",
type: "error"
});
}
};
// Units for the dropdown with readable labels
const timeUnits = [
{ value: "m", label: "Minutes" },
{ value: "h", label: "Hours" },
{ value: "d", label: "Days" }
];
return (
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex w-full items-center justify-between">
<p className="text-xl font-semibold">Secret Share Limits</p>
</div>
<p className="mb-4 mt-2 text-sm text-gray-400">
These settings establish the maximum limits for all Shared Secret parameters within this
organization. Shared secrets cannot be created with values exceeding these limits.
</p>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
{(isAllowed) => (
<form onSubmit={handleSubmit(handleFormSubmit)} autoComplete="off">
<div className="flex max-w-sm gap-4">
<Controller
control={control}
name="maxLifetimeValue"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
tooltipText="The max amount of time that can be set before the secret share link expires."
label="Max Lifetime"
className="w-full"
>
<Input
{...field}
type="number"
min={1}
step={1}
value={field.value}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? "" : parseInt(val, 10));
}}
disabled={!isAllowed}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="maxLifetimeUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Time unit"
>
<Select
value={field.value}
className="pr-2"
onValueChange={field.onChange}
placeholder="Select time unit"
isDisabled={!isAllowed}
>
{timeUnits.map(({ value, label }) => (
<SelectItem
key={value}
value={value}
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">{label}</div>
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div className="flex max-w-sm">
<Controller
control={control}
name="maxViewLimit"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Max Views"
errorText={error?.message}
isError={Boolean(error)}
className="w-full"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={!isAllowed}
>
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
<SelectItem value={String(viewLimitValue || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<Button
colorSchema="secondary"
type="submit"
isLoading={isSubmitting}
disabled={!isDirty || !isAllowed}
className="mt-4"
>
Save
</Button>
</form>
)}
</OrgPermissionCan>
</div>
);
};

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