feat: renamed old frontend and fixed some minor bugs

This commit is contained in:
=
2025-01-06 14:56:49 +05:30
parent 8c902d7699
commit 54377a44d3
2577 changed files with 35240 additions and 35116 deletions
-152
View File
@@ -1,152 +0,0 @@
/* eslint-disable vars-on-top */
/* eslint-disable no-var */
/* eslint-disable func-names */
/* eslint-disable react/jsx-props-no-spreading */
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import { useEffect } from "react";
import { AppProps } from "next/app";
import { useRouter } from "next/router";
import { config } from "@fortawesome/fontawesome-svg-core";
import { QueryClientProvider } from "@tanstack/react-query";
import NProgress from "nprogress";
import { NotificationContainer } from "@app/components/notifications";
import Telemetry from "@app/components/utilities/telemetry/Telemetry";
import { TooltipProvider } from "@app/components/v2";
import { publicPaths } from "@app/const";
import {
AuthProvider,
OrgPermissionProvider,
OrgProvider,
ProjectPermissionProvider,
ServerConfigProvider,
SubscriptionProvider,
UserProvider,
WorkspaceProvider
} from "@app/context";
import { AppLayout } from "@app/layouts";
import ErrorBoundaryWrapper from "@app/layouts/AppLayout/ErrorBoundary";
import { queryClient } from "@app/reactQuery";
import "nprogress/nprogress.css";
import "react-toastify/dist/ReactToastify.css";
import "@fortawesome/fontawesome-svg-core/styles.css";
import "react-day-picker/dist/style.css";
import "../styles/globals.css";
import "@app/i18n";
config.autoAddCss = false;
type NextAppProp = AppProps & {
Component: AppProps["Component"] & { requireAuth: boolean };
};
const App = ({ Component, pageProps, ...appProps }: NextAppProp): JSX.Element => {
const router = useRouter();
useEffect(() => {
// Init for auto capturing
const telemetry = new Telemetry().getInstance();
const handleRouteChange = () => {
if (typeof window !== "undefined") {
telemetry.capture("$pageview");
}
};
router.events.on("routeChangeComplete", handleRouteChange);
return () => {
router.events.off("routeChangeComplete", handleRouteChange);
};
}, [router.events]);
useEffect(() => {
const handleStart = () => NProgress.start();
const handleStop = () => NProgress.done();
router.events.on("routeChangeStart", handleStart);
router.events.on("routeChangeComplete", handleStop);
router.events.on("routeChangeError", handleStop);
return () => {
router.events.off("routeChangeStart", handleStart);
router.events.off("routeChangeComplete", handleStop);
router.events.off("routeChangeError", handleStop);
};
}, [router]);
// If it's one of these routes, don't add the layout (e.g., these routes are external)
if (
publicPaths.includes(`/${appProps.router.pathname.split("/")[1]}`) ||
!Component.requireAuth
) {
return (
<ErrorBoundaryWrapper>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<NotificationContainer />
<ServerConfigProvider>
<UserProvider>
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
</UserProvider>
</ServerConfigProvider>
</TooltipProvider>
</QueryClientProvider>
</ErrorBoundaryWrapper>
);
}
const Layout = Component?.layout || AppLayout;
return (
<ErrorBoundaryWrapper>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<NotificationContainer />
<ServerConfigProvider>
<AuthProvider>
<OrgProvider>
<OrgPermissionProvider>
<WorkspaceProvider>
<ProjectPermissionProvider>
<SubscriptionProvider>
<UserProvider>
<Layout>
<Component {...pageProps} />
</Layout>
</UserProvider>
</SubscriptionProvider>
</ProjectPermissionProvider>
</WorkspaceProvider>
</OrgPermissionProvider>
</OrgProvider>
</AuthProvider>
</ServerConfigProvider>
</TooltipProvider>
</QueryClientProvider>
</ErrorBoundaryWrapper>
);
};
export default App;
/* <Script
src="https://www.googletagmanager.com/gtag/js?id=G-DQ1XLJJGG1"
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){window.dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-DQ1XLJJGG1');
`}
</Script> */
@@ -0,0 +1,357 @@
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
} from "@app/components/v2";
import { useServerConfig, useUser } from "@app/context";
import {
useGetOrganizations,
useGetServerRootKmsEncryptionDetails,
useUpdateServerConfig
} from "@app/hooks/api";
import { AuthPanel } from "./components/AuthPanel";
import { EncryptionPanel } from "./components/EncryptionPanel";
import { IntegrationPanel } from "./components/IntegrationPanel";
import { RateLimitPanel } from "./components/RateLimitPanel";
import { UserPanel } from "./components/UserPanel";
enum TabSections {
Settings = "settings",
Encryption = "encryption",
Auth = "auth",
RateLimit = "rate-limit",
Integrations = "integrations",
Users = "users"
}
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()
});
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 ?? ""
}
});
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 } = formData;
await updateServerConfig({
defaultAuthOrgId: defaultAuthOrgId || null,
allowSignUp: signUpMode !== SignUpModes.Disabled,
allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null,
trustSamlEmails,
trustLdapEmails,
trustOidcEmails
});
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.RateLimit}>Rate Limit</Tab>
<Tab value={TabSections.Integrations}>Integrations</Tab>
<Tab value={TabSections.Users}>Users</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 based
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={() => {
console.log("clearing");
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>
<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.RateLimit}>
<RateLimitPanel />
</TabPanel>
<TabPanel value={TabSections.Integrations}>
<IntegrationPanel />
</TabPanel>
<TabPanel value={TabSections.Users}>
<UserPanel />
</TabPanel>
</Tabs>
</div>
)}
</div>
</div>
</>
);
};
@@ -0,0 +1,252 @@
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, Switch } from "@app/components/v2";
import { useServerConfig } from "@app/context";
import { useUpdateServerConfig } from "@app/hooks/api";
import { LoginMethod } from "@app/hooks/api/admin/types";
const formSchema = z.object({
isEmailEnabled: z.boolean(),
isGoogleEnabled: z.boolean(),
isGithubEnabled: z.boolean(),
isGitlabEnabled: z.boolean(),
isSamlEnabled: z.boolean(),
isLdapEnabled: z.boolean(),
isOidcEnabled: z.boolean()
});
type TAuthForm = z.infer<typeof formSchema>;
export const AuthPanel = () => {
const { config } = useServerConfig();
const { enabledLoginMethods } = config;
const { mutateAsync: updateServerConfig } = useUpdateServerConfig();
const {
control,
handleSubmit,
formState: { isSubmitting, isDirty }
} = useForm<TAuthForm>({
resolver: zodResolver(formSchema),
// if not yet explicitly defined by the admin, all login methods should be enabled by default
values: enabledLoginMethods
? {
isEmailEnabled: enabledLoginMethods.includes(LoginMethod.EMAIL),
isGoogleEnabled: enabledLoginMethods.includes(LoginMethod.GOOGLE),
isGithubEnabled: enabledLoginMethods.includes(LoginMethod.GITHUB),
isGitlabEnabled: enabledLoginMethods.includes(LoginMethod.GITLAB),
isSamlEnabled: enabledLoginMethods.includes(LoginMethod.SAML),
isLdapEnabled: enabledLoginMethods.includes(LoginMethod.LDAP),
isOidcEnabled: enabledLoginMethods.includes(LoginMethod.OIDC)
}
: {
isEmailEnabled: true,
isGoogleEnabled: true,
isGithubEnabled: true,
isGitlabEnabled: true,
isSamlEnabled: true,
isLdapEnabled: true,
isOidcEnabled: true
}
});
const onAuthFormSubmit = async (formData: TAuthForm) => {
try {
const enabledMethods: LoginMethod[] = [];
if (formData.isEmailEnabled) {
enabledMethods.push(LoginMethod.EMAIL);
}
if (formData.isGoogleEnabled) {
enabledMethods.push(LoginMethod.GOOGLE);
}
if (formData.isGithubEnabled) {
enabledMethods.push(LoginMethod.GITHUB);
}
if (formData.isGitlabEnabled) {
enabledMethods.push(LoginMethod.GITLAB);
}
if (formData.isSamlEnabled) {
enabledMethods.push(LoginMethod.SAML);
}
if (formData.isLdapEnabled) {
enabledMethods.push(LoginMethod.LDAP);
}
if (formData.isOidcEnabled) {
enabledMethods.push(LoginMethod.OIDC);
}
if (!enabledMethods.length) {
createNotification({
type: "error",
text: "At least one login method should be enabled."
});
return;
}
await updateServerConfig({
enabledLoginMethods: enabledMethods
});
createNotification({
text: "Login methods have been successfully updated.",
type: "success"
});
} catch (e) {
console.error(e);
createNotification({
type: "error",
text: "Failed to update login methods."
});
}
};
return (
<form
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
onSubmit={handleSubmit(onAuthFormSubmit)}
>
<div className="flex flex-col justify-start">
<div className="mb-2 text-xl font-semibold text-mineshaft-100">Login Methods</div>
<div className="mb-4 max-w-sm text-sm text-mineshaft-400">
Select the login methods you wish to allow for all users of this instance.
</div>
<Controller
control={control}
name="isEmailEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="email-enabled"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-24">Email</p>
</Switch>
</FormControl>
);
}}
/>
<Controller
control={control}
name="isGoogleEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="google-enabled"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-24">Google SSO</p>
</Switch>
</FormControl>
);
}}
/>
<Controller
control={control}
name="isGithubEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="enable-github"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-24">Github SSO</p>
</Switch>
</FormControl>
);
}}
/>
<Controller
control={control}
name="isGitlabEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="enable-gitlab"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-24">Gitlab SSO</p>
</Switch>
</FormControl>
);
}}
/>
<Controller
control={control}
name="isSamlEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="enable-saml"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-24">SAML SSO</p>
</Switch>
</FormControl>
);
}}
/>
<Controller
control={control}
name="isOidcEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="enable-oidc"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-24">OIDC SSO</p>
</Switch>
</FormControl>
);
}}
/>
</div>
<Controller
control={control}
name="isLdapEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="enable-ldap"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-24">LDAP</p>
</Switch>
</FormControl>
);
}}
/>
<Button
className="mt-2"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
Save
</Button>
</form>
);
};
@@ -0,0 +1,138 @@
import { useCallback } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
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";
const formSchema = z.object({
encryptionStrategy: z.nativeEnum(RootKeyEncryptionStrategy)
});
const strategies: Record<RootKeyEncryptionStrategy, string> = {
[RootKeyEncryptionStrategy.Software]: "Software-based Encryption",
[RootKeyEncryptionStrategy.HSM]: "Hardware Security Module (HSM)"
};
type TForm = z.infer<typeof formSchema>;
type Props = {
rootKmsDetails?: TGetServerRootKmsEncryptionDetails;
};
export const EncryptionPanel = ({ rootKmsDetails }: Props) => {
const { mutateAsync: updateEncryptionStrategy } = useUpdateServerEncryptionStrategy();
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
const {
control,
handleSubmit,
formState: { isSubmitting, isDirty }
} = useForm<TForm>({
resolver: zodResolver(formSchema),
values: {
encryptionStrategy:
rootKmsDetails?.strategies?.find((s) => s.enabled)?.strategy ??
RootKeyEncryptionStrategy.Software
}
});
const onSubmit = useCallback(async (formData: TForm) => {
if (!subscription) return;
if (!subscription.hsm) {
handlePopUpOpen("upgradePlan", {
description: "Hardware Security Module's (HSM's), are only available on Enterprise plans."
});
return;
}
try {
await updateEncryptionStrategy(formData.encryptionStrategy);
createNotification({
type: "success",
text: "Encryption strategy updated successfully"
});
} catch {
createNotification({
type: "error",
text: "Failed to update encryption strategy"
});
}
}, []);
return (
<>
<form
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
onSubmit={handleSubmit(onSubmit)}
>
<div className="flex flex-col justify-start">
<div className="flex w-full justify-between">
<div className="mb-2 text-xl font-semibold text-mineshaft-100">
KMS Encryption Strategy
</div>
</div>
<div className="mb-4 max-w-sm text-sm text-mineshaft-400">
Select which type of encryption strategy you want to use for your KMS root key. HSM is
supported on Enterprise plans.
</div>
{!!rootKmsDetails && (
<Controller
control={control}
name="encryptionStrategy"
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}
>
{rootKmsDetails.strategies?.map((strategy) => (
<SelectItem key={strategy.strategy} value={strategy.strategy}>
{strategies[strategy.strategy]}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
)}
</div>
<Button
className="mt-2"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
Save
</Button>
</form>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</>
);
};
@@ -0,0 +1,166 @@
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, FormControl, Input } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { useGetAdminSlackConfig, useUpdateServerConfig } from "@app/hooks/api";
const slackFormSchema = z.object({
clientId: z.string(),
clientSecret: z.string()
});
type TSlackForm = z.infer<typeof slackFormSchema>;
const getCustomSlackAppCreationUrl = () =>
`https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent(
JSON.stringify({
display_information: {
name: "Infisical",
description: "Get real-time Infisical updates in Slack",
background_color: "#c2d62b",
long_description: `This Slack application is designed specifically for use with your self-hosted Infisical instance, allowing seamless integration between your Infisical projects and your Slack workspace. With this integration, your team can stay up-to-date with the latest events, changes, and notifications directly inside Slack.
- Notifications: Receive real-time updates and alerts about critical events in your Infisical projects. Whether it's a new project being created, updates to secrets, or changes to your team's configuration, you will be promptly notified within the designated Slack channels of your choice.
- Customization: Tailor the notifications to your team's specific needs by configuring which types of events trigger alerts and in which channels they are sent.
- Collaboration: Keep your entire team in the loop with notifications that help facilitate more efficient collaboration by ensuring that everyone is aware of important developments in your Infisical projects.
By integrating Infisical with Slack, you can enhance your workflow by combining the power of secure secrets management with the communication capabilities of Slack.`
},
features: {
app_home: {
home_tab_enabled: false,
messages_tab_enabled: false,
messages_tab_read_only_enabled: true
},
bot_user: {
display_name: "Infisical",
always_online: true
}
},
oauth_config: {
redirect_urls: [`${window.origin}/api/v1/workflow-integrations/slack/oauth_redirect`],
scopes: {
bot: ["chat:write.public", "chat:write", "channels:read", "groups:read"]
}
},
settings: {
org_deploy_enabled: false,
socket_mode_enabled: false,
token_rotation_enabled: false
}
})
)}`;
export const IntegrationPanel = () => {
const {
control,
handleSubmit,
setValue,
formState: { isSubmitting, isDirty }
} = useForm<TSlackForm>({
resolver: zodResolver(slackFormSchema)
});
const { data: adminSlackConfig } = useGetAdminSlackConfig();
const { mutateAsync: updateAdminServerConfig } = useUpdateServerConfig();
const [isSlackClientIdFocused, setIsSlackClientIdFocused] = useToggle();
const [isSlackClientSecretFocused, setIsSlackClientSecretFocused] = useToggle();
useEffect(() => {
if (adminSlackConfig) {
setValue("clientId", adminSlackConfig.clientId);
setValue("clientSecret", adminSlackConfig.clientSecret);
}
}, [adminSlackConfig]);
const onSlackFormSubmit = async (data: TSlackForm) => {
await updateAdminServerConfig({
slackClientId: data.clientId,
slackClientSecret: data.clientSecret
});
createNotification({
text: "Updated admin slack configuration",
type: "success"
});
};
return (
<form
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
onSubmit={handleSubmit(onSlackFormSubmit)}
>
<div className="flex flex-col justify-start">
<div className="mb-2 text-xl font-semibold text-mineshaft-100">Slack Integration</div>
<div className="mb-4 max-w-lg text-sm text-mineshaft-300">
Step 1: Create your Infisical Slack App
</div>
<div className="mb-6">
<Button
colorSchema="secondary"
onClick={() => window.open(getCustomSlackAppCreationUrl())}
>
Create Slack App
</Button>
</div>
<div className="mb-4 max-w-lg text-sm text-mineshaft-300">
Step 2: Configure your instance-wide settings to enable integration with Slack. Copy the
values from the App Credentials page of your custom Slack App.
</div>
<Controller
control={control}
name="clientId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client ID"
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
type={isSlackClientIdFocused ? "text" : "password"}
onFocus={() => setIsSlackClientIdFocused.on()}
onBlur={() => setIsSlackClientIdFocused.off()}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="clientSecret"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client Secret"
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
type={isSlackClientSecretFocused ? "text" : "password"}
onFocus={() => setIsSlackClientSecretFocused.on()}
onBlur={() => setIsSlackClientSecretFocused.off()}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
</div>
<Button
className="mt-2"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
Save
</Button>
</form>
);
};
@@ -0,0 +1,240 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { Button, ContentLoader, FormControl, Input } from "@app/components/v2";
import { useSubscription } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetRateLimit, useUpdateRateLimit } from "@app/hooks/api";
const formSchema = z.object({
readRateLimit: z.number(),
writeRateLimit: z.number(),
secretsRateLimit: z.number(),
authRateLimit: z.number(),
inviteUserRateLimit: z.number(),
mfaRateLimit: z.number(),
publicEndpointLimit: z.number()
});
type TRateLimitForm = z.infer<typeof formSchema>;
export const RateLimitPanel = () => {
const { data: rateLimit, isPending } = useGetRateLimit();
const { subscription } = useSubscription();
const { mutateAsync: updateRateLimit } = useUpdateRateLimit();
const { handlePopUpToggle, handlePopUpOpen, popUp } = usePopUp(["upgradePlan"] as const);
const {
control,
handleSubmit,
formState: { isSubmitting, isDirty }
} = useForm<TRateLimitForm>({
resolver: zodResolver(formSchema),
values: {
// eslint-disable-next-line
readRateLimit: rateLimit?.readRateLimit ?? 600,
writeRateLimit: rateLimit?.writeRateLimit ?? 200,
secretsRateLimit: rateLimit?.secretsRateLimit ?? 60,
authRateLimit: rateLimit?.authRateLimit ?? 60,
inviteUserRateLimit: rateLimit?.inviteUserRateLimit ?? 30,
mfaRateLimit: rateLimit?.mfaRateLimit ?? 20,
publicEndpointLimit: rateLimit?.publicEndpointLimit ?? 30
}
});
const onRateLimitFormSubmit = async (formData: TRateLimitForm) => {
try {
if (subscription && !subscription.customRateLimits) {
handlePopUpOpen("upgradePlan");
return;
}
const {
readRateLimit,
writeRateLimit,
secretsRateLimit,
authRateLimit,
inviteUserRateLimit,
mfaRateLimit,
publicEndpointLimit
} = formData;
await updateRateLimit({
readRateLimit,
writeRateLimit,
secretsRateLimit,
authRateLimit,
inviteUserRateLimit,
mfaRateLimit,
publicEndpointLimit
});
createNotification({
text: "Rate limits have been successfully updated. Please allow at least 10 minutes for the changes to take effect.",
type: "success"
});
} catch (e) {
console.error(e);
createNotification({
type: "error",
text: "Failed to update rate limiting setting."
});
}
};
return isPending ? (
<ContentLoader />
) : (
<form
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
onSubmit={handleSubmit(onRateLimitFormSubmit)}
>
<div className="mb-8 flex flex-col justify-start">
<div className="mb-4 text-xl font-semibold text-mineshaft-100">Configure rate limits</div>
<Controller
control={control}
name="readRateLimit"
defaultValue={300}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Global read requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue={300}
name="writeRateLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Global write requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue={300}
name="secretsRateLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue={300}
name="authRateLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Auth requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue={300}
name="inviteUserRateLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="User invitation requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue={300}
name="mfaRateLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Multi factor auth requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue={300}
name="publicEndpointLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret sharing requests per minute"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
</div>
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting || !isDirty}>
Save
</Button>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can configure custom rate limits if you switch to Infisical's Enterprise plan."
/>
</form>
);
};
@@ -0,0 +1,183 @@
import { useState } from "react";
import { faMagnifyingGlass, faUsers, faXmark } 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 {
Badge,
Button,
DeleteActionModal,
EmptyState,
IconButton,
Input,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useSubscription, useUser } from "@app/context";
import { useDebounce, usePopUp } from "@app/hooks";
import { useAdminDeleteUser, useAdminGetUsers } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const UserPanelTable = ({
handlePopUpOpen
}: {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeUser", "upgradePlan"]>,
data?: {
username: string;
id: string;
}
) => void;
}) => {
const [searchUserFilter, setSearchUserFilter] = useState("");
const { user } = useUser();
const userId = user?.id || "";
const [debounedSearchTerm] = useDebounce(searchUserFilter, 500);
const { subscription } = useSubscription();
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({
limit: 20,
searchTerm: debounedSearchTerm
});
const isEmpty = !isPending && !data?.pages?.[0].length;
return (
<>
<Input
value={searchUserFilter}
onChange={(e) => setSearchUserFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search users..."
/>
<div className="mt-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-5/12">Name</Th>
<Th className="w-5/12">Username</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="users" />}
{!isPending &&
data?.pages?.map((users) =>
users.map(({ username, email, firstName, lastName, id, superAdmin }) => {
const name = firstName || lastName ? `${firstName} ${lastName}` : "-";
return (
<Tr key={`user-${id}`} className="w-full">
<Td className="w-5/12">
{name}
{superAdmin && (
<Badge variant="primary" className="ml-2">
Server Admin
</Badge>
)}
</Td>
<Td className="w-5/12">{email}</Td>
<Td>
{userId !== id && (
<div className="flex justify-end">
<IconButton
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
isDisabled={userId === id}
onClick={() => {
if (!subscription?.instanceUserManagement) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("removeUser", { username, id });
}}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
)}
</Td>
</Tr>
);
})
)}
</TBody>
</Table>
{!isPending && isEmpty && <EmptyState title="No users found" icon={faUsers} />}
</TableContainer>
{!isEmpty && (
<Button
className="mt-4 py-3 text-sm"
isFullWidth
variant="star"
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()}
>
{hasNextPage ? "Load More" : "End of list"}
</Button>
)}
</div>
</>
);
};
export const UserPanel = () => {
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"removeUser",
"upgradePlan"
] as const);
const { mutateAsync: deleteUser } = useAdminDeleteUser();
const handleRemoveUser = async () => {
const { id } = popUp?.removeUser?.data as { id: string; username: string };
try {
await deleteUser(id);
createNotification({
type: "success",
text: "Successfully deleted user"
});
} catch {
createNotification({
type: "error",
text: "Error deleting user"
});
}
handlePopUpClose("removeUser");
};
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}
deleteKey="remove"
title={`Are you sure you want to delete User with username ${
(popUp?.removeUser?.data as { id: string; username: string })?.username || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("removeUser", isOpen)}
onDeleteApproved={handleRemoveUser}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="Deleting users via Admin UI is only available on Infisical's Pro plan and above."
/>
</div>
);
};
@@ -0,0 +1,7 @@
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,243 @@
import { useState } from "react";
import { Helmet } from "react-helmet";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { generateBackupPDFAsync } from "@app/components/utilities/generateBackupPDF";
// TODO(akhilmhdh): rewrite this into module functions in lib
import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, ContentLoader, FormControl, Input } from "@app/components/v2";
import { useServerConfig } from "@app/context";
import { useCreateAdminUser, useSelectOrganization } from "@app/hooks/api";
import { generateUserBackupKey, generateUserPassKey } from "@app/lib/crypto";
import { DownloadBackupKeys } from "./components/DownloadBackupKeys";
const formSchema = z
.object({
email: z.string().email().trim(),
firstName: z.string().trim(),
lastName: z.string().trim().optional(),
password: z.string().trim().min(14).max(100),
confirmPassword: z.string().trim()
})
.refine((data) => data.password === data.confirmPassword, {
message: "Password don't match",
path: ["confirmPassword"]
});
type TFormSchema = z.infer<typeof formSchema>;
enum SignupSteps {
DetailsForm = "details-form",
BackupKey = "backup-key"
}
export const SignUpPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const {
control,
handleSubmit,
getValues,
formState: { isSubmitting }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema)
});
const [step, setStep] = useState(SignupSteps.DetailsForm);
const { config } = useServerConfig();
const { mutateAsync: createAdminUser } = useCreateAdminUser();
const { mutateAsync: selectOrganization } = useSelectOrganization();
const handleFormSubmit = async ({ email, password, firstName, lastName }: TFormSchema) => {
// avoid multi submission
if (isSubmitting) return;
try {
const { privateKey, ...userPass } = await generateUserPassKey(email, password);
const res = await createAdminUser({
email,
password,
firstName,
lastName,
...userPass
});
SecurityClient.setToken(res.token);
saveTokenToLocalStorage({
publicKey: userPass.publicKey,
encryptedPrivateKey: userPass.encryptedPrivateKey,
iv: userPass.encryptedPrivateKeyIV,
tag: userPass.encryptedPrivateKeyTag,
privateKey
});
await selectOrganization({ organizationId: res.organization.id });
// TODO(akhilmhdh): This is such a confusing pattern and too unreliable
// Will be refactored in next iteration to make it url based rather than local storage ones
// Part of migration to nextjs 14
localStorage.setItem("orgData.id", res.organization.id);
setStep(SignupSteps.BackupKey);
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to create admin"
});
}
};
const handleBackupKeyGenerate = async () => {
try {
const { email, password, firstName, lastName } = getValues();
const generatedKey = await generateUserBackupKey(email, password);
await generateBackupPDFAsync({
generatedKey,
personalEmail: email,
personalName: `${firstName} ${lastName}`
});
navigate({ to: "/admin" });
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to generate backup"
});
}
};
if (config?.initialized && step === SignupSteps.DetailsForm)
return <ContentLoader text="Redirecting to admin page..." />;
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 px-6">
<Helmet>
<title>{t("common.head-title", { title: t("signup.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("signup.og-title") ?? ""} />
<meta name="og:description" content={t("signup.og-description") ?? ""} />
</Helmet>
<div className="flex items-center justify-center">
<AnimatePresence mode="wait">
{step === SignupSteps.DetailsForm && (
<motion.div
className="text-mineshaft-200"
key="panel-1"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<div className="flex flex-col items-center space-y-2 text-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
<div className="pt-4 text-4xl">Welcome to Infisical</div>
<div className="pb-4 text-bunker-300">Create your first Super Admin Account</div>
</div>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="mt-8">
<div className="flex items-center space-x-4">
<Controller
control={control}
name="firstName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="First name"
errorText={error?.message}
isError={Boolean(error)}
>
<Input isFullWidth size="md" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="lastName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Last name"
errorText={error?.message}
isError={Boolean(error)}
>
<Input isFullWidth size="md" {...field} />
</FormControl>
)}
/>
</div>
<Controller
control={control}
name="email"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Email"
errorText={error?.message}
isError={Boolean(error)}
>
<Input isFullWidth size="md" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="password"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password"
errorText={error?.message}
isError={Boolean(error)}
>
<Input isFullWidth size="md" type="password" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="confirmPassword"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Confirm password"
errorText={error?.message}
isError={Boolean(error)}
>
<Input isFullWidth size="md" type="password" {...field} />
</FormControl>
)}
/>
</div>
<Button
type="submit"
colorSchema="primary"
variant="outline_bg"
isFullWidth
className="mt-4"
isLoading={isSubmitting}
>
Continue
</Button>
</form>
</motion.div>
)}
{step === SignupSteps.BackupKey && (
<motion.div
className="text-mineshaft-200"
key="panel-2"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<DownloadBackupKeys onGenerate={handleBackupKeyGenerate} />
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
};
@@ -0,0 +1,56 @@
import { useTranslation } from "react-i18next";
import { faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@app/components/v2";
import { useToggle } from "@app/hooks";
type Props = {
onGenerate: () => Promise<void>;
};
export const DownloadBackupKeys = ({ onGenerate }: Props): JSX.Element => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useToggle();
return (
<div className="mx-auto mb-36 flex h-full w-full flex-col items-center md:mb-16 md:px-6">
<p className="flex flex-col items-center justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
<FontAwesomeIcon
icon={faWarning}
className="mb-6 ml-2 mr-3 pt-1 text-6xl text-bunker-200"
/>
{t("signup.step4-message")}
</p>
<div className="text-md mt-8 flex w-full max-w-md flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 pb-2 text-center text-bunker-300 md:min-w-[24rem] lg:w-1/6">
<div className="m-2 mx-auto mt-4 flex w-full flex-row items-center rounded-md px-3 text-center text-bunker-300 md:mt-8 md:min-w-[23rem] lg:w-1/6">
<span className="mb-2">
{t("signup.step4-description1")} {t("signup.step4-description3")}
</span>
</div>
<div className="mx-auto mb-2 mt-2 flex w-full flex-col items-center justify-center px-3 text-center text-sm md:mb-4 md:mt-4 md:min-w-[20rem] md:max-w-md md:text-left lg:w-1/6">
<div className="text-l w-full py-1 text-lg">
<Button
onClick={async () => {
try {
setIsLoading.on();
await onGenerate();
} finally {
setIsLoading.off();
}
}}
size="sm"
isFullWidth
className="h-12"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
>
Download PDF
</Button>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1 @@
export { DownloadBackupKeys } from "./DownloadBackupKeys";
@@ -0,0 +1,14 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { SignUpPage } from "./SignUpPage";
export const Route = createFileRoute("/_restrict-login-signup/admin/signup")({
component: SignUpPage,
beforeLoad: ({ context }) => {
if (context.serverConfig.initialized) {
throw redirect({
to: "/login"
});
}
}
});
-30
View File
@@ -1,30 +0,0 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { AdminLayout } from "@app/layouts";
import { AdminDashboardPage } from "@app/views/admin/DashboardPage";
const AdminDashboard = () => {
const { t } = useTranslation();
return (
<>
<Head>
<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") ?? ""} />
</Head>
<div className="h-full">
<AdminDashboardPage />
</div>
</>
);
};
export default AdminDashboard;
AdminDashboard.requireAuth = true;
AdminDashboard.layout = AdminLayout;
+14
View File
@@ -0,0 +1,14 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { AdminLayout } from "@app/layouts/AdminLayout";
export const Route = createFileRoute("/_authenticate/_inject-org-details/admin/_admin-layout")({
component: AdminLayout,
beforeLoad: ({ context }) => {
if (!context.user.superAdmin) {
throw redirect({
to: "/organization/secret-manager/overview"
});
}
}
});
-21
View File
@@ -1,21 +0,0 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { SignUpPage } from "@app/views/admin/SignUpPage";
export default function LoginPage() {
const { t } = useTranslation();
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 px-6">
<Head>
<title>{t("common.head-title", { title: t("signup.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("signup.og-title") ?? ""} />
<meta name="og:description" content={t("signup.og-description") ?? ""} />
</Head>
<SignUpPage />
</div>
);
}
-16
View File
@@ -1,16 +0,0 @@
import SecurityClient from "@app/components/utilities/SecurityClient";
/**
* This function is used to check if the user is authenticated.
* To do that, we get their tokens from cookies, and verify if they are good.
*/
const checkAuth = async () => {
return SecurityClient.fetchCall("/api/v1/auth/checkAuth", {
method: "POST",
headers: {
"Content-Type": "application/json"
}
}).then((res) => res);
};
export default checkAuth;
@@ -1,25 +0,0 @@
import SecurityClient from "@app/components/utilities/SecurityClient";
/**
* Will create a new integration session and return it for the given org
* @returns
*/
const createNewIntegrationSession = (organizationId: string) =>
SecurityClient.fetchCall("/api/v1/secret-scanning/create-installation-session/organization", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
organizationId
})
}).then(async (res) => {
if (res && res.status === 200) {
return res.json();
}
console.log("Failed to create integration session");
console.log("response", res);
return undefined;
});
export default createNewIntegrationSession;
@@ -1,25 +0,0 @@
import SecurityClient from "@app/components/utilities/SecurityClient";
/**
* Will create a new integration session and return it for the given org
* @returns
*/
const getInstallationStatus = (organizationId: string) =>
SecurityClient.fetchCall(
`/api/v1/secret-scanning/installation-status/organization/${organizationId}`,
{
method: "GET",
headers: {
"Content-Type": "application/json"
}
}
).then(async (res) => {
if (res && res.status === 200) {
return (await res.json()).appInstallationCompleted;
}
console.log("Failed to check installation status");
console.log("response", res);
return undefined;
});
export default getInstallationStatus;
@@ -1,58 +0,0 @@
import SecurityClient from "@app/components/utilities/SecurityClient";
export type IGitRisks = {
id: string;
description: string;
startLine: string;
endLine: string;
startColumn: string;
endColumn: string;
match: string;
secret: string;
file: string;
symlinkFile: string;
commit: string;
entropy: string;
author: string;
email: string;
date: string;
message: string;
tags: string[];
ruleID: string;
fingerprint: string;
status: string;
isFalsePositive: boolean; // New field for marking risks as false positives
isResolved: boolean; // New field for marking risks as resolved
riskOwner: string | null; // New field for setting a risk owner (nullable string)
installationId: string;
repositoryId: string;
repositoryLink: string;
repositoryFullName: string;
pusher: {
name: string;
email: string;
};
createdAt: string;
orgId: string;
};
/**
* Will create a new integration session and return it for the given org
* @returns
*/
const getRisksByOrganization = (oranizationId: string): Promise<IGitRisks[]> =>
SecurityClient.fetchCall(`/api/v1/secret-scanning/organization/${oranizationId}/risks`, {
method: "GET",
headers: {
"Content-Type": "application/json"
}
}).then(async (res) => {
if (res && res.status === 200) {
return (await res.json()).risks;
}
console.log("Failed to fetch risks");
return undefined;
});
export default getRisksByOrganization;
@@ -1,25 +0,0 @@
import SecurityClient from "@app/components/utilities/SecurityClient";
/**
* Will create a new integration session and return it for the given org
* @returns
*/
const linkGitAppInstallationWithOrganization = (installationId: string, sessionId: string) =>
SecurityClient.fetchCall("/api/v1/secret-scanning/link-installation", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
installationId,
sessionId
})
}).then(async (res) => {
if (res && res.status === 200) {
return true;
}
console.log("Failed to link installation to organization");
return undefined;
});
export default linkGitAppInstallationWithOrganization;
@@ -1,34 +0,0 @@
import SecurityClient from "@app/components/utilities/SecurityClient";
export enum RiskStatus {
RESOLVED_FALSE_POSITIVE = "RESOLVED_FALSE_POSITIVE",
RESOLVED_REVOKED = "RESOLVED_REVOKED",
RESOLVED_NOT_REVOKED = "RESOLVED_NOT_REVOKED",
UNRESOLVED = "UNRESOLVED"
}
/**
* Will create a new integration session and return it for the given org
* @returns
*/
const updateRiskStatus = (organizationId: string, riskId: string, status: RiskStatus) =>
SecurityClient.fetchCall(
`/api/v1/secret-scanning/organization/${organizationId}/risks/${riskId}/status`,
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
status
})
}
).then(async (res) => {
if (res && res.status === 200) {
return res.json();
}
console.log("Failed to link installation to organization");
return undefined;
});
export default updateRiskStatus;
@@ -1,5 +1,4 @@
import Head from "next/head";
import Image from "next/image";
import { Helmet } from "react-helmet";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -21,7 +20,7 @@ const getTerminalCliToken = () => {
return data as string;
};
export default function CliRedirect() {
export const CliRedirectPage = () => {
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
initialState: false
});
@@ -37,13 +36,13 @@ export default function CliRedirect() {
return (
<div className="flex flex-col justify-between bg-bunker-800 md:h-screen">
<Head>
<Helmet>
<title>Infisical CLI | Login Successful!</title>
<link rel="icon" href="/infisical.ico" />
</Head>
</Helmet>
<div className="flex h-screen w-screen flex-col items-center justify-center space-y-4 text-gray-200">
<div className="mb-4 flex justify-center">
<Image src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical Logo" />
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical Logo" />
</div>
{cliToken ? (
<>
@@ -51,10 +50,11 @@ export default function CliRedirect() {
<p className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-3xl font-medium text-transparent">
Unable to reach CLI
</p>
<p className="text-light mb-1 text-lg text-mineshaft-400 text-center">
Your login was successful but, Infisical couldn&apos;t automatically push your login token to the CLI.
<p className="text-light mb-1 text-center text-lg text-mineshaft-400">
Your login was successful but, Infisical couldn&apos;t automatically push your login
token to the CLI.
</p>
<p className="text-light mb-1 text-lg text-mineshaft-400 text-center">
<p className="text-light mb-1 text-center text-lg text-mineshaft-400">
Please copy the token below and manually provide it to your CLI.
</p>
</div>
@@ -66,7 +66,7 @@ export default function CliRedirect() {
colorSchema="primary"
ariaLabel="copy to clipboard"
onClick={copyUrlToClipboard}
className=" flex items-center rounded py-2"
className="flex items-center rounded py-2"
>
<FontAwesomeIcon className="pr-2" icon={isUrlCopied ? faCheck : faCopy} /> Copy to
clipboard
@@ -87,4 +87,4 @@ export default function CliRedirect() {
</div>
</div>
);
}
};
@@ -0,0 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { CliRedirectPage } from './CliRedirectPage'
export const Route = createFileRoute('/cli-redirect')({
component: CliRedirectPage,
})
@@ -1,16 +1,15 @@
import React from "react";
import Head from "next/head";
import { Helmet } from "react-helmet";
export default function EmailNotFeriviedPage() {
export const EmailNotVerifiedPage = () => {
return (
<div className="flex flex-col justify-between bg-bunker-800 md:h-screen">
<Head>
<Helmet>
<title>Request a New Invite</title>
<link rel="icon" href="/infisical.ico" />
</Head>
</Helmet>
<div className="flex h-screen w-screen flex-col items-center justify-center text-gray-200">
<p className="text-6xl">Oops.</p>
<p className="mt-2 mb-1 text-xl">Your email was not verified. </p>
<p className="mb-1 mt-2 text-xl">Your email was not verified. </p>
<p className="text-xl">Please try again.</p>
<p className="text-md mt-8 max-w-sm text-center text-gray-600">
Note: If it still doesn&apos;t work, please reach out to us at support@infisical.com
@@ -18,4 +17,4 @@ export default function EmailNotFeriviedPage() {
</div>
</div>
);
}
};
@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { EmailNotVerifiedPage } from "./EmailNotVerifiedPage";
export const Route = createFileRoute("/_restrict-login-signup/email-not-verified")({
component: EmailNotVerifiedPage
});
@@ -0,0 +1,154 @@
import { useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { Button, Input } from "@app/components/v2";
import { useServerConfig } from "@app/context";
import { loginLDAPRedirect } from "@app/hooks/api/auth/queries";
export const LoginLdapPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { config } = useServerConfig();
const queryParams = new URLSearchParams(window.location.search);
const passedOrgSlug = queryParams.get("organizationSlug");
const passedUsername = queryParams.get("username");
const [organizationSlug, setOrganizationSlug] = useState(
config.defaultAuthOrgSlug || passedOrgSlug || ""
);
const [username, setUsername] = useState(passedUsername || "");
const [password, setPassword] = useState("");
const handleSubmission = async (e: React.FormEvent) => {
e.preventDefault();
try {
const { nextUrl } = await loginLDAPRedirect({
organizationSlug,
username,
password
});
if (!nextUrl) {
createNotification({
text: "Login unsuccessful. Double-check your credentials and try again.",
type: "error"
});
return;
}
createNotification({
text: "Successfully logged in",
type: "success"
});
window.open(nextUrl);
window.close();
} catch {
createNotification({
text: "Login unsuccessful. Double-check your credentials and try again.",
type: "error"
});
}
// TODO: add callback port support
// const callbackPort = queryParams.get("callback_port");
// window.open(`/api/v1/ldap/redirect/saml2/${ssoIdentifier}${callbackPort ? `?callback_port=${callbackPort}` : ""}`);
// window.close();
};
return (
<div className="flex h-screen flex-col justify-center bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6 pb-28">
<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>
<Link to="/">
<div className="mb-4 mt-20 flex justify-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
</div>
</Link>
<div className="mx-auto w-full max-w-md md:px-6">
<p className="mx-auto mb-8 flex w-max justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
What&apos;s your LDAP Login?
</p>
<form onSubmit={handleSubmission}>
{!config.defaultAuthOrgSlug && !passedOrgSlug && (
<div className="relative mx-auto flex max-h-24 w-full min-w-[20rem] items-center justify-center rounded-lg md:max-h-28 md:min-w-[22rem] lg:w-1/6">
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
<Input
value={organizationSlug}
onChange={(e) => setOrganizationSlug(e.target.value)}
type="text"
placeholder="Enter your organization slug..."
isRequired
autoComplete="email"
id="email"
className="h-12"
/>
</div>
</div>
)}
<div className="relative mx-auto mt-2 flex max-h-24 w-full min-w-[20rem] items-center justify-center rounded-lg md:max-h-28 md:min-w-[22rem] lg:w-1/6">
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
type="text"
placeholder="Enter your LDAP username..."
isRequired
autoComplete="email"
id="email"
className="h-12"
isDisabled={passedUsername !== null}
/>
</div>
</div>
<div className="relative mx-auto mt-2 flex max-h-24 w-full min-w-[20rem] items-center justify-center rounded-lg md:max-h-28 md:min-w-[22rem] lg:w-1/6">
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
<Input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
placeholder="Enter your LDAP password..."
isRequired
autoComplete="current-password"
id="current-password"
className="select:-webkit-autofill:focus h-10"
/>
</div>
</div>
<div className="mx-auto mt-4 flex w-full min-w-[20rem] items-center justify-center rounded-md text-center md:min-w-[22rem] lg:w-1/6">
<Button
type="submit"
colorSchema="primary"
variant="outline_bg"
isFullWidth
className="h-14"
>
{t("login.login")}
</Button>
</div>
</form>
<div className="mt-4 flex flex-row items-center justify-center">
<button
onClick={() => {
navigate({ to: "/login" });
}}
type="button"
className="mt-2 cursor-pointer text-sm text-bunker-300 duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"
>
{t("login.other-option")}
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,17 @@
import { createFileRoute, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { LoginLdapPage } from "./LoginLDAPPage";
const LoginLdapPageQueryParamsSchema = z.object({
organizationSlug: z.string().catch("")
});
export const Route = createFileRoute("/_restrict-login-signup/login/ldap")({
component: LoginLdapPage,
validateSearch: zodValidator(LoginLdapPageQueryParamsSchema),
search: {
middlewares: [stripSearchParams({ organizationSlug: "" })]
}
});
@@ -0,0 +1,48 @@
import { NavigateFn, useNavigate } from "@tanstack/react-router";
import { useServerConfig } from "@app/context";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { queryClient } from "@app/hooks/api/reactQuery";
import { userKeys } from "@app/hooks/api/users";
import { ProjectType } from "@app/hooks/api/workspace/types";
export const navigateUserToOrg = async (navigate: NavigateFn, organizationId?: string) => {
const userOrgs = await fetchOrganizations();
const nonAuthEnforcedOrgs = userOrgs.filter((org) => !org.authEnforced);
if (organizationId) {
localStorage.setItem("orgData.id", organizationId);
navigate({ to: "/organization/secret-manager/overview" });
return;
}
if (nonAuthEnforcedOrgs.length > 0) {
// user is part of at least 1 non-auth enforced org
const userOrg = nonAuthEnforcedOrgs[0] && nonAuthEnforcedOrgs[0].id;
localStorage.setItem("orgData.id", userOrg);
navigate({ to: `/organization/${ProjectType.SecretManager}/overview` as const });
} else {
// user is not part of any non-auth enforced orgs
localStorage.removeItem("orgData.id");
navigate({ to: "/organization/none" });
}
};
export const useNavigateToSelectOrganization = () => {
const { config } = useServerConfig();
const navigate = useNavigate();
const navigateToSelectOrganization = async (cliCallbackPort?: string) => {
if (!config.defaultAuthOrgId) {
queryClient.invalidateQueries({ queryKey: userKeys.getUser });
}
navigate({
to: "/login/select-organization",
search: { callback_port: cliCallbackPort, org_id: config.defaultAuthOrgId }
});
};
return { navigateToSelectOrganization };
};
@@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { Link } from "@tanstack/react-router";
import { isLoggedIn } from "@app/hooks/api/reactQuery";
import { InitialStep, SSOStep } from "./components";
import { useNavigateToSelectOrganization } from "./Login.utils";
export const LoginPage = () => {
const { t } = useTranslation();
const [step, setStep] = useState(0);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { navigateToSelectOrganization } = useNavigateToSelectOrganization();
const queryParams = new URLSearchParams(window.location.search);
useEffect(() => {
// TODO(akhilmhdh): workspace will be controlled by a workspace context
const handleRedirects = async () => {
try {
const callbackPort = queryParams?.get("callback_port");
// case: a callback port is set, meaning it's a cli login request: redirect to select org with callback port
if (callbackPort) {
navigateToSelectOrganization(callbackPort);
} else {
// case: no callback port, meaning it's a regular login request: redirect to select org
navigateToSelectOrganization();
}
} catch {
console.log("Error - Not logged in yet");
}
};
if (isLoggedIn()) {
handleRedirects();
}
}, []);
const renderView = () => {
switch (step) {
case 0:
return (
<InitialStep
setStep={setStep}
email={email}
setEmail={setEmail}
password={password}
setPassword={setPassword}
/>
);
case 2:
return <SSOStep setStep={setStep} type="SAML" />;
case 3:
return <SSOStep setStep={setStep} type="OIDC" />;
default:
return <div />;
}
};
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 px-6">
<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>
<Link to="/">
<div className="mb-4 mt-20 flex justify-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
</div>
</Link>
<div className="pb-28">{renderView()}</div>;
</div>
);
};
@@ -0,0 +1,408 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { faGithub, faGitlab, faGoogle } from "@fortawesome/free-brands-svg-icons";
import { faLock } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import HCaptcha from "@hcaptcha/react-hcaptcha";
import { Link, useNavigate } from "@tanstack/react-router";
import Error from "@app/components/basic/Error";
import { RegionSelect } from "@app/components/navigation/RegionSelect";
import { createNotification } from "@app/components/notifications";
import attemptCliLogin from "@app/components/utilities/attemptCliLogin";
import attemptLogin from "@app/components/utilities/attemptLogin";
import { CAPTCHA_SITE_KEY } from "@app/components/utilities/config";
import { Button, IconButton, Input, Tooltip } from "@app/components/v2";
import { useServerConfig } from "@app/context";
import { useFetchServerStatus } from "@app/hooks/api";
import { LoginMethod } from "@app/hooks/api/admin/types";
import { AuthMethod } from "@app/hooks/api/users/types";
import { useNavigateToSelectOrganization } from "../../Login.utils";
type Props = {
setStep: (step: number) => void;
email: string;
setEmail: (email: string) => void;
password: string;
setPassword: (email: string) => void;
};
export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: Props) => {
const navigate = useNavigate();
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [loginError, setLoginError] = useState(false);
const { config } = useServerConfig();
const queryParams = new URLSearchParams(window.location.search);
const [captchaToken, setCaptchaToken] = useState("");
const [shouldShowCaptcha, setShouldShowCaptcha] = useState(false);
const captchaRef = useRef<HCaptcha>(null);
const { data: serverDetails } = useFetchServerStatus();
const { navigateToSelectOrganization } = useNavigateToSelectOrganization();
const redirectToSaml = (orgSlug: string) => {
const callbackPort = queryParams.get("callback_port");
const redirectUrl = `/api/v1/sso/redirect/saml2/organizations/${orgSlug}${
callbackPort ? `?callback_port=${callbackPort}` : ""
}`;
navigate({ to: redirectUrl });
};
const redirectToOidc = (orgSlug: string) => {
const callbackPort = queryParams.get("callback_port");
const redirectUrl = `/api/v1/sso/oidc/login?orgSlug=${orgSlug}${
callbackPort ? `&callbackPort=${callbackPort}` : ""
}`;
navigate({ to: redirectUrl });
};
useEffect(() => {
if (serverDetails?.samlDefaultOrgSlug) redirectToSaml(serverDetails.samlDefaultOrgSlug);
}, [serverDetails?.samlDefaultOrgSlug]);
const handleSaml = () => {
if (config.defaultAuthOrgSlug) {
redirectToSaml(config.defaultAuthOrgSlug);
} else {
setStep(2);
}
};
const handleOidc = () => {
if (config.defaultAuthOrgSlug) {
redirectToOidc(config.defaultAuthOrgSlug);
} else {
setStep(3);
}
};
const shouldDisplayLoginMethod = (method: LoginMethod) =>
!config.enabledLoginMethods || config.enabledLoginMethods.includes(method);
const handleLogin = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
try {
if (!email || !password) {
return;
}
setIsLoading(true);
if (queryParams && queryParams.get("callback_port")) {
const callbackPort = queryParams.get("callback_port");
// attemptCliLogin
const isCliLoginSuccessful = await attemptCliLogin({
email: email.toLowerCase(),
password,
captchaToken
});
if (isCliLoginSuccessful && isCliLoginSuccessful.success) {
navigateToSelectOrganization(callbackPort!);
} else {
setLoginError(true);
createNotification({
text: "CLI login unsuccessful. Double-check your credentials and try again.",
type: "error"
});
}
} else {
const isLoginSuccessful = await attemptLogin({
email: email.toLowerCase(),
password,
captchaToken
});
if (isLoginSuccessful && isLoginSuccessful.success) {
// case: login was successful
navigateToSelectOrganization();
createNotification({
text: "Successfully logged in",
type: "success"
});
}
}
} catch (err: any) {
console.error(err);
if (err.response.data.error === "User Locked") {
createNotification({
title: err.response.data.error,
text: err.response.data.message,
type: "error"
});
setIsLoading(false);
return;
}
if (err.response.data.error === "Captcha Required") {
setShouldShowCaptcha(true);
setIsLoading(false);
return;
}
setLoginError(true);
createNotification({
text: "Login unsuccessful. Double-check your credentials and try again.",
type: "error"
});
}
if (captchaRef.current) {
captchaRef.current.resetCaptcha();
}
setCaptchaToken("");
setIsLoading(false);
};
if (config.defaultAuthOrgAuthEnforced && config.defaultAuthOrgAuthMethod) {
return (
<form
onSubmit={handleLogin}
className="mx-auto flex w-full flex-col items-center justify-center"
>
<h1 className="mb-8 bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
Login to Infisical
</h1>
<RegionSelect />
{config.defaultAuthOrgAuthMethod === AuthMethod.SAML && (
<div className="w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Button
colorSchema="primary"
variant="outline_bg"
onClick={handleSaml}
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
className="mx-0 h-10 w-full"
>
Continue with SAML
</Button>
</div>
)}
{config.defaultAuthOrgAuthMethod === AuthMethod.OIDC && (
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Button
colorSchema="primary"
variant="outline_bg"
onClick={handleOidc}
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
className="mx-0 h-10 w-full"
>
Continue with OIDC
</Button>
</div>
)}
</form>
);
}
return (
<form
onSubmit={handleLogin}
className="mx-auto flex w-full flex-col items-center justify-center"
>
<h1 className="mb-8 bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
Login to Infisical
</h1>
<RegionSelect />
{shouldDisplayLoginMethod(LoginMethod.SAML) && (
<div className="w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Button
colorSchema="primary"
variant="outline_bg"
onClick={handleSaml}
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
className="mx-0 h-10 w-full"
>
Continue with SAML
</Button>
</div>
)}
{shouldDisplayLoginMethod(LoginMethod.OIDC) && (
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Button
colorSchema="primary"
variant="outline_bg"
onClick={handleOidc}
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
className="mx-0 h-10 w-full"
>
Continue with OIDC
</Button>
</div>
)}
{shouldDisplayLoginMethod(LoginMethod.LDAP) && (
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Button
colorSchema="primary"
variant="outline_bg"
onClick={() => {
navigate({ to: "/login/ldap" });
}}
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
className="mx-0 h-10 w-full"
>
Continue with LDAP
</Button>
</div>
)}
<div className="mt-2 flex w-1/4 min-w-[21.2rem] gap-2 md:min-w-[20.1rem] lg:w-1/6">
{shouldDisplayLoginMethod(LoginMethod.GOOGLE) && (
<Tooltip position="bottom" content={t("login.continue-with-google")}>
<IconButton
ariaLabel={t("login.continue-with-google")}
colorSchema="primary"
variant="outline_bg"
onClick={() => {
const callbackPort = queryParams.get("callback_port");
window.open(
`/api/v1/sso/redirect/google${
callbackPort ? `?callback_port=${callbackPort}` : ""
}`
);
window.close();
}}
className="h-10 w-full bg-mineshaft-600"
>
<FontAwesomeIcon icon={faGoogle} />
</IconButton>
</Tooltip>
)}
{shouldDisplayLoginMethod(LoginMethod.GITHUB) && (
<Tooltip position="bottom" content="Continue with GitHub">
<IconButton
ariaLabel="Login continue with GitHub"
colorSchema="primary"
variant="outline_bg"
onClick={() => {
const callbackPort = queryParams.get("callback_port");
window.open(
`/api/v1/sso/redirect/github${
callbackPort ? `?callback_port=${callbackPort}` : ""
}`
);
window.close();
}}
className="h-10 w-full bg-mineshaft-600"
>
<FontAwesomeIcon icon={faGithub} />
</IconButton>
</Tooltip>
)}
{shouldDisplayLoginMethod(LoginMethod.GITLAB) && (
<Tooltip position="bottom" content="Continue with GitLab">
<IconButton
ariaLabel="Login continue with GitLab"
colorSchema="primary"
variant="outline_bg"
onClick={() => {
const callbackPort = queryParams.get("callback_port");
window.open(
`/api/v1/sso/redirect/gitlab${
callbackPort ? `?callback_port=${callbackPort}` : ""
}`
);
window.close();
}}
className="h-10 w-full bg-mineshaft-600"
>
<FontAwesomeIcon icon={faGitlab} />
</IconButton>
</Tooltip>
)}
</div>
{(!config.enabledLoginMethods ||
(shouldDisplayLoginMethod(LoginMethod.EMAIL) && config.enabledLoginMethods.length > 1)) && (
<div className="my-4 flex w-1/4 min-w-[20rem] flex-row items-center py-2 lg:w-1/6">
<div className="w-full border-t border-mineshaft-400/60" />
<span className="mx-2 text-xs text-mineshaft-200">or</span>
<div className="w-full border-t border-mineshaft-400/60" />
</div>
)}
{shouldDisplayLoginMethod(LoginMethod.EMAIL) && (
<>
<div className="w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
type="email"
placeholder="Enter your email..."
isRequired
autoComplete="username"
className="h-10"
/>
</div>
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
placeholder="Enter your password..."
isRequired
autoComplete="current-password"
id="current-password"
className="select:-webkit-autofill:focus h-10"
/>
</div>
{shouldShowCaptcha && (
<div className="mt-4">
<HCaptcha
theme="dark"
sitekey={CAPTCHA_SITE_KEY}
onVerify={(token) => setCaptchaToken(token)}
ref={captchaRef}
/>
</div>
)}
<div className="mt-4 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Button
disabled={shouldShowCaptcha && captchaToken === ""}
type="submit"
size="sm"
isFullWidth
className="h-10"
colorSchema="primary"
variant="solid"
isLoading={isLoading}
>
{" "}
Continue with Email{" "}
</Button>
</div>
</>
)}
{!isLoading && loginError && <Error text={t("login.error-login") ?? ""} />}
{config.allowSignUp &&
(shouldDisplayLoginMethod(LoginMethod.EMAIL) ||
shouldDisplayLoginMethod(LoginMethod.GOOGLE) ||
shouldDisplayLoginMethod(LoginMethod.GITHUB) ||
shouldDisplayLoginMethod(LoginMethod.GITLAB)) ? (
<div className="mt-6 flex flex-row text-sm text-bunker-400">
<Link href="/signup">
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
Don&apos;t have an account yet? {t("login.create-account")}
</span>
</Link>
</div>
) : (
<div className="mt-4" />
)}
{shouldDisplayLoginMethod(LoginMethod.EMAIL) && (
<div className="mt-2 flex flex-row text-sm text-bunker-400">
<Link href="/verify-email">
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
Forgot password? Recover your account
</span>
</Link>
</div>
)}
</form>
);
};
@@ -0,0 +1 @@
export { InitialStep } from "./InitialStep";
@@ -0,0 +1,379 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import HCaptcha from "@hcaptcha/react-hcaptcha";
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 attemptCliLogin from "@app/components/utilities/attemptCliLogin";
import attemptLogin from "@app/components/utilities/attemptLogin";
import { CAPTCHA_SITE_KEY } from "@app/components/utilities/config";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Input, Spinner } from "@app/components/v2";
import { SessionStorageKeys } from "@app/const";
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 { navigateUserToOrg, useNavigateToSelectOrganization } from "../../Login.utils";
type Props = {
providerAuthToken: string;
email: string;
password: string;
setPassword: (password: string) => void;
};
export const PasswordStep = ({ providerAuthToken, email, password, setPassword }: Props) => {
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const navigate = useNavigate();
const { mutateAsync: selectOrganization } = useSelectOrganization();
const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const { navigateToSelectOrganization } = useNavigateToSelectOrganization();
const { callbackPort, organizationId, hasExchangedPrivateKey } = jwtDecode(
providerAuthToken
) as any;
const handleExchange = async () => {
try {
setIsLoading(true);
const oauthLogin = await oauthTokenExchange({
email,
providerAuthToken
});
// attemptCliLogin
const cliUrl = `http://127.0.0.1:${callbackPort}/`;
// unset provider auth token in case it was used
SecurityClient.setProviderAuthToken("");
// set JWT token
SecurityClient.setToken(oauthLogin.token);
const privateKey = await fetchMyPrivateKey();
localStorage.setItem("PRIVATE_KEY", privateKey);
// case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org
if (organizationId) {
const finishWithOrgWorkflow = async () => {
const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ organizationId });
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
setMfaSuccessCallback(() => finishWithOrgWorkflow);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
return;
}
if (callbackPort) {
console.log("organization id was present. new JWT token to be used in CLI:", token);
const instance = axios.create();
const payload = {
privateKey,
email,
JTWToken: token
};
await instance.post(cliUrl, 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" });
return;
}
await navigateUserToOrg(navigate, organizationId);
};
await finishWithOrgWorkflow();
}
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
// if the user has no orgs, navigate to the create org page
else {
const userOrgs = await fetchOrganizations();
// case: user has orgs, so we navigate the user to select an org
if (userOrgs.length > 0) {
navigateToSelectOrganization(callbackPort);
}
// case: no orgs found, so we navigate the user to create an org
else {
await navigateUserToOrg(navigate);
}
}
} catch (err: any) {
setIsLoading(false);
console.error(err);
if (err.response.data.error === "User Locked") {
createNotification({
title: err.response.data.error,
text: err.response.data.message,
type: "error"
});
return;
}
createNotification({
text: "Login unsuccessful. Double-check your master password and try again.",
type: "error"
});
}
};
useEffect(() => {
if (hasExchangedPrivateKey) {
handleExchange();
}
}, []);
const [captchaToken, setCaptchaToken] = useState("");
const [shouldShowCaptcha, setShouldShowCaptcha] = useState(false);
const captchaRef = useRef<HCaptcha>(null);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
try {
setIsLoading(true);
if (callbackPort) {
// attemptCliLogin
const isCliLoginSuccessful = await attemptCliLogin({
email,
password,
providerAuthToken,
captchaToken
});
if (isCliLoginSuccessful && isCliLoginSuccessful.success) {
const cliUrl = `http://127.0.0.1:${callbackPort}/`;
// case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org
if (organizationId) {
const finishWithOrgWorkflow = async () => {
const { token, isMfaEnabled, mfaMethod } = await selectOrganization({
organizationId
});
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => finishWithOrgWorkflow);
return;
}
console.log("organization id was present. new JWT token to be used in CLI:", token);
const instance = axios.create();
const payload = {
...isCliLoginSuccessful.loginResponse,
JTWToken: token
};
await instance.post(cliUrl, 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" });
};
await finishWithOrgWorkflow();
return;
}
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
// if the user has no orgs, navigate to the create org page
const userOrgs = await fetchOrganizations();
// case: user has orgs, so we navigate the user to select an org
if (userOrgs.length > 0) {
navigateToSelectOrganization(callbackPort);
}
// case: no orgs found, so we navigate the user to create an org
else {
await navigateUserToOrg(navigate);
}
}
} else {
const loginAttempt = await attemptLogin({
email,
password,
providerAuthToken,
captchaToken
});
if (loginAttempt && loginAttempt.success) {
// case: login was successful
setIsLoading(false);
createNotification({
text: "Successfully logged in",
type: "success"
});
// case: organization ID is present from the provider auth token -- navigate directly to the org
if (organizationId) {
await navigateUserToOrg(navigate, organizationId);
}
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
// if the user has no orgs, navigate to the create org page
else {
const userOrgs = await fetchOrganizations();
if (userOrgs.length > 0) {
navigateToSelectOrganization();
} else {
await navigateUserToOrg(navigate);
}
}
}
}
} catch (err: any) {
setIsLoading(false);
console.error(err);
if (err.response.data.error === "User Locked") {
createNotification({
title: err.response.data.error,
text: err.response.data.message,
type: "error"
});
return;
}
if (err.response.data.error === "Captcha Required") {
setShouldShowCaptcha(true);
return;
}
createNotification({
text: "Login unsuccessful. Double-check your master password and try again.",
type: "error"
});
}
if (captchaRef.current) {
captchaRef.current.resetCaptcha();
}
setCaptchaToken("");
};
if (shouldShowMfa) {
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">
<Mfa
email={email}
successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
closeMfa={() => toggleShowMfa.off()}
/>
</div>
);
}
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">
<Spinner />
<p className="text-white opacity-80">Loading, please wait</p>
</div>
);
}
return (
<form onSubmit={handleLogin} className="mx-auto h-full w-full max-w-md px-6 pt-8">
<div className="mb-8">
<p className="mx-auto mb-4 flex w-max justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
What&apos;s your Infisical password?
</p>
</div>
<div className="relative mx-auto flex max-h-24 w-full min-w-[22rem] items-center justify-center rounded-lg md:max-h-28 lg:w-1/6">
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
<Input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
placeholder="Enter your password..."
isRequired
autoComplete="current-password"
id="current-password"
className="h-12"
/>
</div>
</div>
{shouldShowCaptcha && (
<div className="mx-auto mt-4 flex w-full min-w-[22rem] items-center justify-center lg:w-1/6">
<HCaptcha
theme="dark"
sitekey={CAPTCHA_SITE_KEY}
onVerify={(token) => setCaptchaToken(token)}
ref={captchaRef}
/>
</div>
)}
<div className="mx-auto mt-4 flex w-full min-w-[22rem] items-center justify-center rounded-md text-center lg:w-1/6">
<Button
disabled={shouldShowCaptcha && captchaToken === ""}
type="submit"
colorSchema="primary"
variant="outline_bg"
isFullWidth
isLoading={isLoading}
className="h-14"
>
{t("login.login")}
</Button>
</div>
<div className="mx-auto mt-4 flex w-max flex-col items-center text-xs text-bunker-400">
<span className="max-w-sm px-4 text-center duration-200">
Infisical Master Password serves as a decryption mechanism so that even Google is not able
to access your secrets.
</span>
<Link href="/verify-email">
<span className="mt-2 cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
{t("login.forgot-password")}
</span>
</Link>
</div>
<div className="flex flex-row items-center justify-center">
<button
onClick={() => {
navigate({ to: "/login" });
}}
type="button"
className="mt-2 cursor-pointer text-xs text-bunker-400 duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"
>
{t("login.other-option")}
</button>
</div>
</form>
);
};
@@ -0,0 +1 @@
export { PasswordStep } from "./PasswordStep";
@@ -0,0 +1,82 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Input } from "@app/components/v2";
type Props = {
setStep: (step: number) => void;
type: "SAML" | "OIDC";
};
export const SSOStep = ({ setStep, type }: Props) => {
const [ssoIdentifier, setSSOIdentifier] = useState("");
const { t } = useTranslation();
const queryParams = new URLSearchParams(window.location.search);
const handleSubmission = (e: React.FormEvent) => {
e.preventDefault();
const callbackPort = queryParams.get("callback_port");
if (type === "SAML") {
window.open(
`/api/v1/sso/redirect/saml2/organizations/${ssoIdentifier}${
callbackPort ? `?callback_port=${callbackPort}` : ""
}`
);
} else {
window.open(
`/api/v1/sso/oidc/login?orgSlug=${ssoIdentifier}${
callbackPort ? `&callbackPort=${callbackPort}` : ""
}`
);
}
window.close();
};
return (
<div className="mx-auto w-full max-w-md md:px-6">
<p className="mx-auto mb-8 flex w-max justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
What&apos;s your organization slug?
</p>
<form onSubmit={handleSubmission}>
<div className="relative mx-auto flex max-h-24 w-full min-w-[20rem] items-center justify-center rounded-lg md:max-h-28 md:min-w-[22rem] lg:w-1/6">
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
<Input
value={ssoIdentifier}
onChange={(e) => setSSOIdentifier(e.target.value)}
type="text"
placeholder="acme-123"
isRequired
autoComplete="email"
id="email"
className="h-12"
/>
</div>
</div>
<div className="mx-auto mt-4 flex w-full min-w-[20rem] items-center justify-center rounded-md text-center md:min-w-[22rem] lg:w-1/6">
<Button
type="submit"
colorSchema="primary"
variant="outline_bg"
isFullWidth
className="h-14"
>
Continue with {type}
</Button>
</div>
</form>
<div className="mt-4 flex flex-row items-center justify-center">
<button
onClick={() => {
setStep(0);
}}
type="button"
className="mt-2 cursor-pointer text-sm text-bunker-300 duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"
>
{t("login.other-option")}
</button>
</div>
</div>
);
};
@@ -0,0 +1 @@
export { SSOStep } from "./SSOStep";
@@ -0,0 +1,5 @@
export { InitialStep } from "./InitialStep";
export { SSOStep } from "./SSOStep";
// SSO-specific step
export { PasswordStep } from "./PasswordStep";
@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { LoginPage } from "./LoginPage";
export const Route = createFileRoute("/_restrict-login-signup/login/")({
component: LoginPage
});
@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { Link, useSearch } from "@tanstack/react-router";
import { jwtDecode } from "jwt-decode";
import { ROUTE_PATHS } from "@app/const/routes";
import { PasswordStep } from "../LoginPage/components";
export const LoginSsoPage = () => {
const { t } = useTranslation();
const search = useSearch({ from: ROUTE_PATHS.Auth.LoginSSO.id });
const token = search.token as string;
const [step, setStep] = useState(0);
const [password, setPassword] = useState("");
const { username, isUserCompleted } = jwtDecode(token) as any;
useEffect(() => {
if (isUserCompleted) {
setStep(1);
}
}, []);
const renderView = () => {
switch (step) {
case 0:
return <div />;
case 1:
return (
<PasswordStep
providerAuthToken={token}
email={username}
password={password}
setPassword={setPassword}
/>
);
default:
return <div />;
}
};
return (
<div className="flex h-screen flex-col justify-center bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6 pb-28">
<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>
<Link href="/">
<div className="mb-4 mt-20 flex justify-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
</div>
</Link>
<div>{renderView()}</div>;
</div>
);
};
@@ -0,0 +1,14 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { LoginSsoPage } from "./LoginSsoPage";
const LoginSSOQueryParamsSchema = z.object({
token: z.string()
});
export const Route = createFileRoute("/_restrict-login-signup/login/sso")({
component: LoginSsoPage,
validateSearch: zodValidator(LoginSSOQueryParamsSchema)
});
@@ -1,26 +1,24 @@
import crypto from "crypto";
import { FormEvent, useState } from "react";
import Image from "next/image";
import { useRouter } from "next/router";
import { faCheck, faX } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import jsrp from "jsrp";
import queryString from "query-string";
import Button from "@app/components/basic/buttons/Button";
import InputField from "@app/components/basic/InputField";
import passwordCheck from "@app/components/utilities/checks/password/PasswordCheck";
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
import { Button } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { useResetPassword, useVerifyPasswordResetCode } from "@app/hooks/api";
import { getBackupEncryptedPrivateKey } from "@app/hooks/api/auth/queries";
import { deriveArgonKey } from "../components/utilities/cryptography/crypto";
// eslint-disable-next-line new-cap
const client = new jsrp.client();
export default function PasswordReset() {
export const PasswordResetPage = () => {
const [verificationToken, setVerificationToken] = useState("");
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
@@ -37,12 +35,16 @@ export default function PasswordReset() {
const [passwordErrorLowEntropy, setPasswordErrorLowEntropy] = useState(false);
const [passwordErrorBreached, setPasswordErrorBreached] = useState(false);
const router = useRouter();
const navigate = useNavigate();
const search = useSearch({ from: ROUTE_PATHS.Auth.PasswordResetPage.id });
const { mutateAsync: verifyPasswordResetCodeMutateAsync } = useVerifyPasswordResetCode();
const {
mutateAsync: verifyPasswordResetCodeMutateAsync,
isPending: isVerifyPasswordResetLoading
} = useVerifyPasswordResetCode();
const { mutateAsync: resetPasswordMutateAsync } = useResetPassword();
const parsedUrl = queryString.parse(router.asPath.split("?")[1]);
const parsedUrl = search;
const token = parsedUrl.token as string;
const email = (parsedUrl.to as string)?.replace(" ", "+").trim();
@@ -89,7 +91,7 @@ export default function PasswordReset() {
password: newPassword
},
async () => {
client.createVerifier(async (err: any, result: { salt: string; verifier: string }) => {
client.createVerifier(async (_err: any, result: { salt: string; verifier: string }) => {
const derivedKey = await deriveArgonKey({
password: newPassword,
salt: result.salt,
@@ -137,7 +139,7 @@ export default function PasswordReset() {
verificationToken
});
router.push("/login");
navigate({ to: "/login" });
setLoading(false);
});
@@ -148,15 +150,14 @@ export default function PasswordReset() {
// Click a button to confirm email
const stepConfirmEmail = (
<div className="my-32 mx-1 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker py-6 px-4 drop-shadow-xl md:max-w-lg md:px-6">
<div className="mx-1 my-32 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 py-6 drop-shadow-xl md:max-w-lg md:px-6">
<p className="mb-8 flex justify-center bg-gradient-to-br from-sky-400 to-primary bg-clip-text text-center text-4xl font-semibold text-transparent">
Confirm your email
</p>
<Image src="/images/envelope.svg" height={262} width={410} alt="verify email" />
<div className="mx-auto mt-4 mb-2 flex max-h-24 max-w-md flex-col items-center justify-center px-4 text-lg md:p-2">
<img src="/images/envelope.svg" height={262} width={410} alt="verify email" />
<div className="mx-auto mb-2 mt-4 flex max-h-24 max-w-md flex-col items-center justify-center px-4 text-lg md:p-2">
<Button
text="Confirm Email"
onButtonPressed={async () => {
onClick={async () => {
try {
const response = await verifyPasswordResetCodeMutateAsync({
email,
@@ -167,11 +168,14 @@ export default function PasswordReset() {
setStep(2);
} catch (err) {
console.log("ERROR", err);
router.push("/email-not-verified");
navigate({ to: "/email-not-verified" });
}
}}
isLoading={isVerifyPasswordResetLoading}
size="lg"
/>
>
Confirm Email
</Button>
</div>
</div>
);
@@ -180,7 +184,7 @@ export default function PasswordReset() {
const stepInputBackupKey = (
<form
onSubmit={getEncryptedKeyHandler}
className="my-32 mx-1 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 pt-6 pb-3 drop-shadow-xl md:max-w-lg md:px-6"
className="mx-1 my-32 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 pb-3 pt-6 drop-shadow-xl md:max-w-lg md:px-6"
>
<p className="mx-auto mb-4 flex w-max justify-center text-2xl font-semibold text-bunker-100 md:text-3xl">
Enter your backup key
@@ -205,7 +209,9 @@ export default function PasswordReset() {
</div>
<div className="mx-auto mt-4 flex max-h-20 w-full max-w-md flex-col items-center justify-center text-sm md:p-2">
<div className="text-l m-8 mt-6 px-8 py-3 text-lg">
<Button type="submit" text="Submit Backup Key" onButtonPressed={() => {}} size="lg" />
<Button type="submit" size="lg">
Submit Backup Key
</Button>
</div>
</div>
</form>
@@ -215,7 +221,7 @@ export default function PasswordReset() {
const stepEnterNewPassword = (
<form
onSubmit={resetPasswordHandler}
className="my-32 mx-1 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 pt-6 pb-3 drop-shadow-xl md:max-w-lg md:px-6"
className="mx-1 my-32 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 pb-3 pt-6 drop-shadow-xl md:max-w-lg md:px-6"
>
<p className="mx-auto flex w-max justify-center text-2xl font-semibold text-bunker-100 md:text-3xl">
Enter new password
@@ -267,7 +273,7 @@ export default function PasswordReset() {
passwordErrorEscapeChar ||
passwordErrorLowEntropy ||
passwordErrorBreached ? (
<div className="mx-2 mt-3 mb-2 flex w-full max-w-md flex-col items-start rounded-md bg-white/5 px-2 py-2">
<div className="mx-2 mb-2 mt-3 flex w-full max-w-md flex-col items-start rounded-md bg-white/5 px-2 py-2">
<div className="mb-1 text-sm text-gray-400">Password should contain:</div>
<div className="ml-1 flex flex-row items-center justify-start">
{passwordErrorTooShort ? (
@@ -367,13 +373,9 @@ export default function PasswordReset() {
)}
<div className="mx-auto mt-4 flex max-h-20 w-full max-w-md flex-col items-center justify-center text-sm md:p-2">
<div className="text-l m-8 mt-6 px-8 py-3 text-lg">
<Button
type="submit"
text="Submit New Password"
onButtonPressed={() => setLoading(true)}
size="lg"
loading={loading}
/>
<Button type="submit" onClick={() => setLoading(true)} size="lg" isLoading={loading}>
Submit New Password
</Button>
</div>
</div>
</form>
@@ -386,4 +388,4 @@ export default function PasswordReset() {
{step === 3 && stepEnterNewPassword}
</div>
);
}
};
@@ -0,0 +1,15 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { PasswordResetPage } from "./PasswordResetPage";
const PasswordResetPageQueryParamsSchema = z.object({
token: z.string(),
to: z.string()
});
export const Route = createFileRoute("/_restrict-login-signup/password-reset")({
component: PasswordResetPage,
validateSearch: zodValidator(PasswordResetPageQueryParamsSchema)
});
@@ -1,10 +1,10 @@
import { useEffect } from "react";
export default function LoginProviderError() {
export const LoginProviderErrorPage = () => {
useEffect(() => {
window.localStorage.setItem("PROVIDER_AUTH_ERROR", "err");
window.close();
}, []);
return <div />;
}
};
@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { LoginProviderErrorPage } from "./ProviderErrorPage";
export const Route = createFileRoute("/_restrict-login-signup/login/provider/error")({
component: LoginProviderErrorPage
});
@@ -0,0 +1,18 @@
import { useEffect } from "react";
import { useSearch } from "@tanstack/react-router";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { ROUTE_PATHS } from "@app/const/routes";
export const LoginProviderSuccessPage = () => {
const search = useSearch({
from: ROUTE_PATHS.Auth.ProviderSuccessPage.id
});
useEffect(() => {
SecurityClient.setProviderAuthToken(search.token);
window.close();
}, []);
return <div />;
};
@@ -0,0 +1,14 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { LoginProviderSuccessPage } from "./ProviderSuccessPage";
const LoginProviderSuccessQuerySchema = z.object({
token: z.string()
});
export const Route = createFileRoute("/_restrict-login-signup/login/provider/success")({
component: LoginProviderSuccessPage,
validateSearch: zodValidator(LoginProviderSuccessQuerySchema)
});
@@ -1,17 +1,16 @@
import Head from "next/head";
import Image from "next/image";
import { Helmet } from "react-helmet";
/**
* This is the page that shows up when a user's invitation
* to join a project/organization on Infisical has expired
*/
export default function RequestNewInvite() {
export const RequestNewInvitePage = () => {
return (
<div className="flex flex-col justify-between bg-bunker-700 md:h-screen">
<Head>
<Helmet>
<title>Request a New Invite</title>
<link rel="icon" href="/infisical.ico" />
</Head>
</Helmet>
<div className="mt-8 flex h-screen w-screen flex-col items-center justify-center text-bunker-200">
<p className="text-4xl text-primary-100">Oops, your invite has expired.</p>
<p className="my-4 text-lg">Ask your admin for a new one.</p>
@@ -20,7 +19,7 @@ export default function RequestNewInvite() {
doesn&apos;t work, please reach out to us at support@infisical.com
</p>
<div className="">
<Image
<img
src="/images/invitation-expired.svg"
height={500}
width={800}
@@ -30,4 +29,4 @@ export default function RequestNewInvite() {
</div>
</div>
);
}
};
@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { RequestNewInvitePage } from "./RequestNewInvitePage";
export const Route = createFileRoute("/_restrict-login-signup/requestnewinvite")({
component: RequestNewInvitePage
});
@@ -1,15 +1,14 @@
import { useCallback, useEffect, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import Head from "next/head";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
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 jwt_decode from "jwt-decode";
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";
@@ -23,11 +22,11 @@ import {
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 { getAuthToken, isLoggedIn } from "@app/reactQuery";
import { navigateUserToOrg } from "@app/views/Login/Login.utils";
import { Mfa } from "@app/views/Login/Mfa";
import { navigateUserToOrg } from "../LoginPage/Login.utils";
const LoadingScreen = () => {
return (
@@ -38,13 +37,13 @@ const LoadingScreen = () => {
);
};
export default function LoginPage() {
const router = useRouter();
export const SelectOrganizationPage = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const organizations = useGetOrganizations();
const selectOrg = useSelectOrganization();
const { data: user, isLoading: userLoading } = useGetUser();
const { data: user, isPending: userLoading } = useGetUser();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true);
@@ -61,11 +60,11 @@ export default function LoginPage() {
try {
console.log("Logging out...");
await logout.mutateAsync();
router.push("/login");
navigate({ to: "/login" });
} catch (error) {
console.error(error);
}
}, [logout, router]);
}, [logout, navigate]);
const handleSelectOrganization = useCallback(
async (organization: Organization) => {
@@ -144,10 +143,10 @@ export default function LoginPage() {
})
);
});
router.push("/cli-redirect");
navigate({ to: "/cli-redirect" });
// cli page
} else {
navigateUserToOrg(router, organization.id);
navigateUserToOrg(navigate, organization.id);
}
},
[selectOrg]
@@ -157,15 +156,15 @@ export default function LoginPage() {
const authToken = getAuthToken();
if (authToken && !callbackPort) {
const decodedJwt = jwt_decode(authToken) as any;
const decodedJwt = jwtDecode(authToken) as any;
if (decodedJwt?.organizationId) {
navigateUserToOrg(router, decodedJwt.organizationId);
navigateUserToOrg(navigate, decodedJwt.organizationId);
}
}
if (!isLoggedIn()) {
router.push("/login");
navigate({ to: "/login" });
}
}, []);
@@ -173,15 +172,15 @@ export default function LoginPage() {
if (callbackPort) {
handleCliRedirect();
}
}, [router]);
}, [navigate]);
useEffect(() => {
if (organizations.isLoading || !organizations.data) return;
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) {
router.push("/org/none");
navigate({ to: "/organization/none" });
} else if (organizations.data.length === 1) {
if (callbackPort) {
handleCliRedirect();
@@ -192,7 +191,7 @@ export default function LoginPage() {
} else {
setIsInitialOrgCheckLoading(false);
}
}, [organizations.isLoading, organizations.data]);
}, [organizations.isPending, organizations.data]);
useEffect(() => {
if (defaultSelectedOrg) {
@@ -210,13 +209,13 @@ export default function LoginPage() {
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">
<Head>
<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") ?? ""} />
</Head>
</Helmet>
{shouldShowMfa ? (
<Mfa
email={user.email as string}
@@ -227,7 +226,7 @@ export default function LoginPage() {
<div className="mx-auto mt-20 w-fit rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg">
<Link href="/">
<div className="mb-4 flex justify-center">
<Image src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
</div>
</Link>
<form className="mx-auto flex w-full flex-col items-center justify-center">
@@ -249,7 +248,7 @@ export default function LoginPage() {
</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.isLoading ? (
{organizations.isPending ? (
<Spinner />
) : (
organizations.data?.map((org) => (
@@ -276,4 +275,4 @@ export default function LoginPage() {
<div className="pb-28" />
</div>
);
}
};
@@ -0,0 +1,18 @@
import { createFileRoute, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { SelectOrganizationPage } from "./SelectOrgPage";
export const SelectOrganizationPageQueryParams = z.object({
org_id: z.string().optional().catch(""),
callback_port: z.coerce.number().optional().catch(undefined)
});
export const Route = createFileRoute("/_restrict-login-signup/login/select-organization")({
component: SelectOrganizationPage,
validateSearch: zodValidator(SelectOrganizationPageQueryParams),
search: {
middlewares: [stripSearchParams({ org_id: "", callback_port: undefined })]
}
});
@@ -2,19 +2,16 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import crypto from "crypto";
import { useEffect, useState } from "react";
import Head from "next/head";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { Helmet } from "react-helmet";
import { faWarning, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import jsrp from "jsrp";
import queryString from "query-string";
import nacl from "tweetnacl";
import { encodeBase64 } from "tweetnacl-util";
import Button from "@app/components/basic/buttons/Button";
import { Mfa } from "@app/components/auth/Mfa";
import InputField from "@app/components/basic/InputField";
import checkPassword from "@app/components/utilities/checks/password/checkPassword";
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
@@ -22,7 +19,7 @@ import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKey";
import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { useServerConfig } from "@app/context";
import { Button } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import {
completeAccountSignupInvite,
@@ -32,8 +29,8 @@ import {
import { MfaMethod } from "@app/hooks/api/auth/types";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { navigateUserToOrg } from "@app/views/Login/Login.utils";
import { Mfa } from "@app/views/Login/Mfa";
import { navigateUserToOrg } from "../LoginPage/Login.utils";
// eslint-disable-next-line new-cap
const client = new jsrp.client();
@@ -49,7 +46,7 @@ type Errors = {
breached?: string;
};
export default function SignupInvite() {
export const SignupInvitePage = () => {
const [password, setPassword] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
@@ -57,19 +54,19 @@ export default function SignupInvite() {
const [lastNameError, setLastNameError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [step, setStep] = useState(1);
const [backupKeyError, setBackupKeyError] = useState(false);
const [backupKeyIssued, setBackupKeyIssued] = useState(false);
const [, setBackupKeyError] = useState(false);
const [, setBackupKeyIssued] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const router = useRouter();
const parsedUrl = queryString.parse(router.asPath.split("?")[1]);
const navigate = useNavigate();
const search = useSearch({ from: "/_restrict-login-signup/signupinvite" });
const parsedUrl = search;
const token = parsedUrl.token as string;
const organizationId = parsedUrl.organization_id as string;
const email = (parsedUrl.to as string)?.replace(" ", "+").trim();
const { config } = useServerConfig();
const queryParams = new URLSearchParams(window.location.search);
@@ -115,7 +112,7 @@ export default function SignupInvite() {
password
},
async () => {
client.createVerifier(async (err, result) => {
client.createVerifier(async (_err, result) => {
try {
const derivedKey = await deriveArgonKey({
password,
@@ -226,15 +223,14 @@ export default function SignupInvite() {
// Step 4 of the sign up process (download the emergency kit pdf)
const stepConfirmEmail = (
<div className="h-7/12 mx-1 mb-36 flex w-full max-w-xs flex-col items-center rounded-xl border border-mineshaft-600 bg-mineshaft-800 py-8 px-4 drop-shadow-xl md:mb-16 md:max-w-lg md:px-6">
<div className="h-7/12 mx-1 mb-36 flex w-full max-w-xs flex-col items-center rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-4 py-8 drop-shadow-xl md:mb-16 md:max-w-lg md:px-6">
<p className="mb-6 flex justify-center text-center text-4xl font-semibold text-primary-100">
Confirm your email
</p>
<Image src="/images/dragon-signupinvite.svg" height={262} width={410} alt="verify email" />
<div className="mx-auto mt-10 mb-2 flex max-h-24 max-w-md flex-col items-center justify-center px-4 text-lg md:p-2">
<img src="/images/dragon-signupinvite.svg" height={262} width={410} alt="verify email" />
<div className="mx-auto mb-2 mt-10 flex max-h-24 max-w-md flex-col items-center justify-center px-4 text-lg md:p-2">
<Button
text="Confirm Email"
onButtonPressed={async () => {
onClick={async () => {
try {
const response = await verifySignupInvite({
email,
@@ -263,7 +259,7 @@ export default function SignupInvite() {
// user will be redirected to dashboard
// if not logged in gets kicked out to login
await navigateUserToOrg(router, organizationId);
await navigateUserToOrg(navigate, organizationId);
};
await redirectExistingUser();
@@ -271,18 +267,20 @@ export default function SignupInvite() {
}
} catch (err) {
console.error(err);
router.push("/requestnewinvite");
navigate({ to: "/requestnewinvite" });
}
}}
size="lg"
/>
>
Confirm Email
</Button>
</div>
</div>
);
// Because this is the invite signup - we directly go to the last step of signup (email is already verified)
const main = (
<div className="h-7/12 mx-auto mb-32 w-max rounded-xl border border-mineshaft-600 bg-mineshaft-800 py-10 px-8 drop-shadow-xl md:mb-16">
<div className="h-7/12 mx-auto mb-32 w-max rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-8 py-10 drop-shadow-xl md:mb-16">
<p className="mx-8 mb-6 flex justify-center bg-gradient-to-tr from-mineshaft-300 to-white bg-clip-text text-4xl font-bold text-transparent md:mx-16">
Almost there!
</p>
@@ -349,20 +347,21 @@ export default function SignupInvite() {
</div>
<div className="mx-auto mt-2 flex max-h-24 max-w-max flex-col items-center justify-center px-2 py-3 text-lg md:px-4 md:py-5">
<Button
text="Sign Up"
onButtonPressed={() => {
onClick={() => {
signupErrorCheck();
}}
loading={isLoading}
isLoading={isLoading}
size="lg"
/>
>
Sign Up
</Button>
</div>
</div>
);
// Step 4 of the sign up process (download the emergency kit pdf)
const step4 = (
<div className="h-7/12 mx-1 mb-36 flex w-full max-w-xs flex-col items-center rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-4 pt-8 pb-6 drop-shadow-xl md:mb-16 md:max-w-lg md:px-6">
<div className="h-7/12 mx-1 mb-36 flex w-full max-w-xs flex-col items-center rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-4 pb-6 pt-8 drop-shadow-xl md:mb-16 md:max-w-lg md:px-6">
<p className="flex justify-center bg-gradient-to-br from-white to-mineshaft-300 bg-clip-text text-center text-4xl font-semibold text-transparent">
Save your Emergency Kit
</p>
@@ -378,8 +377,7 @@ export default function SignupInvite() {
</div>
<div className="mx-auto mt-4 flex max-h-24 max-w-max flex-col items-center justify-center px-2 py-3 text-lg md:px-4 md:py-5">
<Button
text="Download PDF"
onButtonPressed={async () => {
onClick={async () => {
await issueBackupKey({
email,
password,
@@ -387,20 +385,24 @@ export default function SignupInvite() {
setBackupKeyError,
setBackupKeyIssued
});
router.push(`/org/${organizationId}/${ProjectType.SecretManager}/overview`);
navigate({
to: `/organization/${ProjectType.SecretManager}/overview` as const
});
}}
size="lg"
/>
>
Download PDF
</Button>
</div>
</div>
);
return (
<div className="flex h-screen flex-col items-center justify-center bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Head>
<Helmet>
<title>Sign Up</title>
<link rel="icon" href="/infisical.ico" />
</Head>
</Helmet>
{shouldShowMfa ? (
<Mfa
email={email}
@@ -410,9 +412,9 @@ export default function SignupInvite() {
/>
) : (
<>
<Link href="/">
<Link to="/">
<div className="mb-4 mt-20 flex justify-center">
<Image src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical Logo" />
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical Logo" />
</div>
</Link>
{step === 1 ? stepConfirmEmail : step === 2 ? main : step4}
@@ -420,4 +422,4 @@ export default function SignupInvite() {
)}
</div>
);
}
};
@@ -0,0 +1,16 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { SignupInvitePage } from "./SignUpInvitePage";
const SignupInvitePageQueryParamsSchema = z.object({
token: z.string(),
to: z.string(),
organization_id: z.string()
});
export const Route = createFileRoute("/_restrict-login-signup/signupinvite")({
component: SignupInvitePage,
validateSearch: zodValidator(SignupInvitePageQueryParamsSchema)
});
@@ -1,27 +1,22 @@
/* eslint-disable no-nested-ternary */
import { useEffect, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import Head from "next/head";
import Image from "next/image";
import { useRouter } from "next/router";
import { useNavigate } from "@tanstack/react-router";
import CodeInputStep from "@app/components/signup/CodeInputStep";
import DownloadBackupPDF from "@app/components/signup/DonwloadBackupPDFStep";
import EnterEmailStep from "@app/components/signup/EnterEmailStep";
import InitialSignupStep from "@app/components/signup/InitialSignupStep";
import TeamInviteStep from "@app/components/signup/TeamInviteStep";
import UserInfoStep from "@app/components/signup/UserInfoStep";
import CodeInputStep from "@app/components/auth/CodeInputStep";
import DownloadBackupPDF from "@app/components/auth/DonwloadBackupPDFStep";
import EnterEmailStep from "@app/components/auth/EnterEmailStep";
import InitialSignupStep from "@app/components/auth/InitialSignupStep";
import TeamInviteStep from "@app/components/auth/TeamInviteStep";
import UserInfoStep from "@app/components/auth/UserInfoStep";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { useServerConfig } from "@app/context";
import { useVerifySignupEmailVerificationCode } from "@app/hooks/api";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import { ProjectType } from "@app/hooks/api/workspace/types";
/**
* @returns the signup page
*/
export default function SignUp() {
export const SignUpPage = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
@@ -30,7 +25,7 @@ export default function SignUp() {
const [code, setCode] = useState("123456");
const [codeError, setCodeError] = useState(false);
const [step, setStep] = useState(1);
const router = useRouter();
const navigate = useNavigate();
const { data: serverDetails } = useFetchServerStatus();
const [isSignupWithEmail, setIsSignupWithEmail] = useState(false);
const [isCodeInputCheckLoading, setIsCodeInputCheckLoading] = useState(false);
@@ -40,22 +35,10 @@ export default function SignUp() {
useEffect(() => {
if (!config.allowSignUp) {
router.push("/login");
navigate({ to: "/login" });
}
}, [config.allowSignUp]);
useEffect(() => {
const tryAuth = async () => {
try {
const userOrgs = await fetchOrganizations();
router.push(`/org/${userOrgs[0].id}/${ProjectType.SecretManager}/overview`);
} catch (error) {
console.log("Error - Not logged in yet");
}
};
tryAuth();
}, []);
/**
* Goes to the following step (out of 5) of the signup process.
* Step 1 is submitting your email
@@ -90,8 +73,9 @@ export default function SignUp() {
}
if (!serverDetails?.emailConfigured && step === 5) {
const userOrgs = await fetchOrganizations();
router.push(`/org/${userOrgs[0].id}/${ProjectType.SecretManager}/overview`);
navigate({
to: `/organization/${ProjectType.SecretManager}/overview` as const
});
}
})();
}, [step]);
@@ -154,18 +138,18 @@ export default function SignUp() {
};
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 px-6 pb-28 ">
<Head>
<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 px-6 pb-28">
<Helmet>
<title>{t("common.head-title", { title: t("signup.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("signup.og-title") as string} />
<meta name="og:description" content={t("signup.og-description") as string} />
</Head>
</Helmet>
<div className="mb-4 mt-20 flex justify-center">
<Image src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical Logo" />
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical Logo" />
</div>
<form onSubmit={(e) => e.preventDefault()}>{renderView(step)}</form>
</div>
);
}
};
@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { SignUpPage } from "./SignUpPage";
export const Route = createFileRoute("/_restrict-login-signup/signup/")({
component: SignUpPage
});
@@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useSearch } from "@tanstack/react-router";
import { jwtDecode } from "jwt-decode";
import { ROUTE_PATHS } from "@app/const/routes";
import { BackupPDFStep } from "./components/BackupPDFStep";
import { EmailConfirmationStep } from "./components/EmailConfirmationStep";
import { UserInfoSSOStep } from "./components/UserInfoSSOStep";
export const SignupSsoPage = () => {
const { t } = useTranslation();
const search = useSearch({ from: ROUTE_PATHS.Auth.SignUpSsoPage.id });
const token = search.token as string;
const [step, setStep] = useState(0);
const [password, setPassword] = useState("");
const {
username,
email,
organizationName,
organizationSlug,
firstName,
lastName,
authType,
isEmailVerified
} = jwtDecode(token) as any;
useEffect(() => {
if (!isEmailVerified) {
setStep(0);
} else {
setStep(1);
}
}, []);
const renderView = () => {
switch (step) {
case 0:
return (
<EmailConfirmationStep
authType={authType}
username={username}
email={email}
organizationSlug={organizationSlug}
setStep={setStep}
/>
);
case 1:
return (
<UserInfoSSOStep
username={username}
name={`${firstName} ${lastName}`}
providerOrganizationName={organizationName}
password={password}
setPassword={setPassword}
setStep={setStep}
providerAuthToken={token}
/>
);
case 2:
return (
<BackupPDFStep email={username} password={password} name={`${firstName} ${lastName}`} />
);
default:
return <div />;
}
};
return (
<div className="flex min-h-screen flex-col justify-center bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6 pb-28">
<Helmet>
<title>{t("common.head-title", { title: t("signup.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("signup.og-title") as string} />
<meta name="og:description" content={t("signup.og-description") as string} />
</Helmet>
<div className="mb-4 mt-20 flex justify-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical Logo" />
</div>
<div>{renderView()}</div>;
</div>
);
};
@@ -0,0 +1,71 @@
import { useTranslation } from "react-i18next";
import { faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKey";
import { Button } from "@app/components/v2";
import { ProjectType } from "@app/hooks/api/workspace/types";
interface DownloadBackupPDFStepProps {
email: string;
password: string;
name: string;
}
/**
* This is the step of the signup flow where the user downloads the backup pdf
* @param {object} obj
* @param {function} obj.incrementStep - function that moves the user on to the next stage of signup
* @param {string} obj.email - user's email
* @param {string} obj.password - user's password
* @param {string} obj.name - user's name
* @returns
*/
export const BackupPDFStep = ({ email, password, name }: DownloadBackupPDFStepProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<div className="mx-auto mb-36 flex h-full w-full flex-col items-center md:mb-16 md:px-6">
<p className="flex justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
<FontAwesomeIcon icon={faWarning} className="ml-2 mr-3 pt-1 text-2xl text-bunker-200" />
{t("signup.step4-message")}
</p>
<div className="text-md mt-8 flex w-full max-w-md flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 pb-2 text-center text-bunker-300 md:min-w-[24rem] lg:w-1/6">
<div className="m-2 mx-auto mt-4 flex w-full flex-row items-center rounded-md px-3 text-center text-bunker-300 md:mt-8 md:min-w-[23rem] lg:w-1/6">
<span className="mb-2">
{t("signup.step4-description1")} {t("signup.step4-description3")}
</span>
</div>
<div className="mx-auto mb-2 mt-2 flex w-full flex-col items-center justify-center px-3 text-center text-sm md:mb-4 md:mt-4 md:min-w-[20rem] md:max-w-md md:text-left lg:w-1/6">
<div className="text-l w-full py-1 text-lg">
<Button
onClick={async () => {
await issueBackupKey({
email,
password,
personalName: name,
setBackupKeyError: () => {},
setBackupKeyIssued: () => {}
});
navigate({
to: `/organization/${ProjectType.SecretManager}/overview` as const
});
}}
size="sm"
isFullWidth
className="h-12"
colorSchema="primary"
variant="outline_bg"
>
{" "}
Download PDF{" "}
</Button>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1 @@
export { BackupPDFStep } from "./BackupPDFStep";
@@ -0,0 +1,190 @@
// confirm email
// if same email exists, then trigger fn to merge automatically
import { useState } from "react";
import ReactCodeInput from "react-code-input";
import { useNavigate } from "@tanstack/react-router";
import Error from "@app/components/basic/Error";
import { createNotification } from "@app/components/notifications";
import { Button } from "@app/components/v2";
import { useSendEmailVerificationCode, useVerifyEmailVerificationCode } from "@app/hooks/api";
import { UserAliasType } from "@app/hooks/api/users/types";
type Props = {
authType?: UserAliasType;
username: string;
email: string;
organizationSlug: string;
setStep: (step: number) => void;
};
// The style for the verification code input
const props = {
inputStyle: {
fontFamily: "monospace",
margin: "4px",
MozAppearance: "textfield",
width: "55px",
borderRadius: "5px",
fontSize: "24px",
height: "55px",
paddingLeft: "7",
backgroundColor: "#0d1117",
color: "white",
border: "1px solid #2d2f33",
textAlign: "center",
outlineColor: "#8ca542",
borderColor: "#2d2f33"
}
} as const;
const propsPhone = {
inputStyle: {
fontFamily: "monospace",
margin: "4px",
MozAppearance: "textfield",
width: "40px",
borderRadius: "5px",
fontSize: "24px",
height: "40px",
paddingLeft: "7",
backgroundColor: "#0d1117",
color: "white",
border: "1px solid #2d2f33",
textAlign: "center",
outlineColor: "#8ca542",
borderColor: "#2d2f33"
}
} as const;
export const EmailConfirmationStep = ({
authType,
username,
email,
organizationSlug,
setStep
}: Props) => {
const navigate = useNavigate();
const [code, setCode] = useState("");
const [codeError, setCodeError] = useState(false);
const [isResendingVerificationEmail] = useState(false);
const [isLoading] = useState(false);
const { mutateAsync: sendEmailVerificationCode } = useSendEmailVerificationCode();
const { mutateAsync: verifyEmailVerificationCode } = useVerifyEmailVerificationCode();
const checkCode = async () => {
try {
await verifyEmailVerificationCode({ username, code });
setCodeError(false);
createNotification({
text: "Successfully verified code",
type: "success"
});
switch (authType) {
case UserAliasType.SAML: {
window.open(`/api/v1/sso/redirect/saml2/organizations/${organizationSlug}`);
window.close();
break;
}
case UserAliasType.LDAP: {
navigate({ to: "/login/ldap", search: { organizationSlug } });
break;
}
case UserAliasType.OIDC: {
window.open(`/api/v1/sso/oidc/login?orgSlug=${organizationSlug}`);
window.close();
break;
}
default: {
setStep(1);
break;
}
}
} catch {
createNotification({
text: "Failed to verify code",
type: "error"
});
}
setCode("");
};
const resendCode = async () => {
try {
await sendEmailVerificationCode(username);
createNotification({
text: "Successfully resent code",
type: "success"
});
} catch {
createNotification({
text: "Failed to resend code",
type: "error"
});
}
};
return (
<div className="mx-auto h-full w-full pb-4 md:px-8">
<p className="text-md flex justify-center text-bunker-200">
We&apos;ve sent a verification code to {email}
</p>
<div className="mx-auto hidden w-max min-w-[20rem] md:block">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setCode}
{...props}
className="mb-2 mt-6"
/>
</div>
<div className="mx-auto mt-4 block w-max md:hidden">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setCode}
{...propsPhone}
className="mb-2 mt-2"
/>
</div>
{codeError && <Error text="Oops. Your code is wrong. Please try again." />}
<div className="mx-auto mt-2 flex w-1/4 min-w-[20rem] max-w-xs flex-col items-center justify-center text-center text-sm md:max-w-md md:text-left lg:w-[19%]">
<div className="text-l w-full py-1 text-lg">
<Button
type="submit"
onClick={checkCode}
size="sm"
isFullWidth
className="h-14"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
>
{" "}
Verify
</Button>
</div>
</div>
<div className="mx-auto flex max-h-24 w-full max-w-md flex-col items-center justify-center pt-2">
<div className="flex flex-row items-baseline gap-1 text-sm">
<span className="text-bunker-400">Don&apos;t see the code?</span>
<div className="text-md mt-2 flex flex-row text-bunker-400">
<button disabled={isLoading} onClick={resendCode} type="button">
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
{isResendingVerificationEmail ? "Resending..." : "Resend"}
</span>
</button>
</div>
</div>
<p className="pb-2 text-sm text-bunker-400">Make sure to check your spam inbox.</p>
</div>
</div>
);
};
@@ -0,0 +1 @@
export { EmailConfirmationStep } from "./EmailConfirmationStep";
@@ -0,0 +1,322 @@
import crypto from "crypto";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import jsrp from "jsrp";
import nacl from "tweetnacl";
import { encodeBase64 } from "tweetnacl-util";
import { Mfa } from "@app/components/auth/Mfa";
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Input } from "@app/components/v2";
import { initProjectHelper } from "@app/helpers/project";
import { useToggle } from "@app/hooks";
import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
// eslint-disable-next-line new-cap
const client = new jsrp.client();
type Props = {
setStep: (step: number) => void;
username: string;
password: string;
setPassword: (value: string) => void;
name: string;
providerOrganizationName: string;
providerAuthToken?: string;
};
/**
* This is the step of the sign up flow where people provife their name/surname and password
* @param {object} obj
* @param {string} obj.verificationToken - the token which we use to verify the legitness of a user
* @param {string} obj.incrementStep - a function to move to the next signup step
* @param {string} obj.email - email of a user who is signing up
* @param {string} obj.password - user's password
* @param {string} obj.setPassword - function managing the state of user's password
* @param {string} obj.firstName - user's first name
* @param {string} obj.setFirstName - function managing the state of user's first name
* @param {string} obj.lastName - user's lastName
* @param {string} obj.setLastName - function managing the state of user's last name
*/
export const UserInfoSSOStep = ({
username,
name,
providerOrganizationName,
password,
setPassword,
setStep,
providerAuthToken
}: Props) => {
const [nameError, setNameError] = useState(false);
const [organizationName, setOrganizationName] = useState("");
const [organizationNameError, setOrganizationNameError] = useState(false);
const [attributionSource, setAttributionSource] = useState("");
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const { mutateAsync: selectOrganization } = useSelectOrganization();
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
useEffect(() => {
const randomPassword = crypto.randomBytes(32).toString("hex");
setPassword(randomPassword);
if (providerOrganizationName !== undefined) {
setOrganizationName(providerOrganizationName);
}
}, []);
// Verifies if the information that the users entered (name, workspace)
// is there, and if the password matches the criteria.
const signupErrorCheck = async () => {
setIsLoading(true);
let errorCheck = false;
if (!name) {
setNameError(true);
errorCheck = true;
} else {
setNameError(false);
}
if (!organizationName) {
setOrganizationNameError(true);
errorCheck = true;
} else {
setOrganizationNameError(false);
}
if (!errorCheck) {
// Generate a random pair of a public and a private key
const pair = nacl.box.keyPair();
const secretKeyUint8Array = pair.secretKey;
const publicKeyUint8Array = pair.publicKey;
const privateKey = encodeBase64(secretKeyUint8Array);
const publicKey = encodeBase64(publicKeyUint8Array);
localStorage.setItem("PRIVATE_KEY", privateKey);
client.init(
{
username,
password
},
async () => {
client.createVerifier(async (_err: any, result: { salt: string; verifier: string }) => {
try {
// TODO: moduralize into KeyService
const derivedKey = await deriveArgonKey({
password,
salt: result.salt,
mem: 65536,
time: 3,
parallelism: 1,
hashLen: 32
});
if (!derivedKey) throw new Error("Failed to derive key from password");
const key = crypto.randomBytes(32);
// create encrypted private key by encrypting the private
// key with the symmetric key [key]
const {
ciphertext: encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag
} = Aes256Gcm.encrypt({
text: privateKey,
secret: key
});
// create the protected key by encrypting the symmetric key
// [key] with the derived key
const {
ciphertext: protectedKey,
iv: protectedKeyIV,
tag: protectedKeyTag
} = Aes256Gcm.encrypt({
text: key.toString("hex"),
secret: Buffer.from(derivedKey.hash)
});
const response = await completeAccountSignup({
email: username,
password,
firstName: name.split(" ")[0],
lastName: name.split(" ").slice(1).join(" "),
protectedKey,
protectedKeyIV,
protectedKeyTag,
publicKey,
encryptedPrivateKey,
encryptedPrivateKeyIV,
encryptedPrivateKeyTag,
providerAuthToken,
salt: result.salt,
verifier: result.verifier,
organizationName,
attributionSource
});
// unset signup JWT token and set JWT token
SecurityClient.setSignupToken("");
SecurityClient.setToken(response.token);
SecurityClient.setProviderAuthToken("");
saveTokenToLocalStorage({
publicKey,
encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag,
privateKey
});
const userOrgs = await fetchOrganizations();
const orgId = userOrgs[0]?.id;
const completeSignupFlow = async () => {
try {
const { isMfaEnabled, token, mfaMethod } = await selectOrganization({
organizationId: orgId
});
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => completeSignupFlow);
return;
}
// only create example project if not joining existing org
if (!providerOrganizationName) {
await initProjectHelper({
projectName: "Example Project"
});
}
localStorage.setItem("orgData.id", orgId);
setStep(2);
} catch (error) {
setIsLoading(false);
console.error(error);
}
};
await completeSignupFlow();
} catch (error) {
setIsLoading(false);
console.error(error);
}
});
}
);
} else {
setIsLoading(false);
}
};
useEffect(() => {
if (password && providerOrganizationName) {
signupErrorCheck();
}
}, [providerOrganizationName, password]);
if (shouldShowMfa) {
return (
<Mfa
hideLogo
email={username}
successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
closeMfa={() => toggleShowMfa.off()}
/>
);
}
return (
<div className="mx-auto mb-36 h-full w-max rounded-xl md:mb-16 md:px-8">
<p className="text-medium mx-8 mb-6 flex justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-xl font-bold text-transparent md:mx-16">
{t("signup.step3-message")}
</p>
<div className="mx-auto mb-36 h-full w-max rounded-xl py-6 md:mb-16 md:border md:border-mineshaft-600 md:bg-mineshaft-800 md:px-8">
<div className="relative z-0 flex w-full min-w-[20rem] flex-col items-center justify-end rounded-lg py-2 lg:w-1/6">
<p className="mb-1 ml-1 w-full text-left text-sm font-medium text-bunker-300">
Your Name
</p>
<Input
placeholder="Jane Doe"
value={name}
disabled
isRequired
autoComplete="given-name"
className="h-12"
/>
{nameError && (
<p className="ml-1 mt-1 w-full text-left text-xs text-red-600">
Please, specify your name
</p>
)}
</div>
{providerOrganizationName === undefined && (
<div className="relative z-0 flex w-full min-w-[20rem] flex-col items-center justify-end rounded-lg py-2 lg:w-1/6">
<p className="mb-1 ml-1 w-full text-left text-sm font-medium text-bunker-300">
Organization Name
</p>
<Input
placeholder="Infisical"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
isRequired
className="h-12"
maxLength={64}
disabled
/>
{organizationNameError && (
<p className="ml-1 mt-1 w-full text-left text-xs text-red-600">
Please, specify your organization name
</p>
)}
</div>
)}
{providerOrganizationName === undefined && (
<div className="relative z-0 flex w-full min-w-[20rem] flex-col items-center justify-end rounded-lg py-2 lg:w-1/6">
<p className="mb-1 ml-1 w-full text-left text-sm font-medium text-bunker-300">
Where did you hear about us? <span className="font-light">(optional)</span>
</p>
<Input
placeholder=""
onChange={(e) => setAttributionSource(e.target.value)}
value={attributionSource}
className="h-12"
/>
</div>
)}
<div className="mx-auto mt-2 flex w-1/4 min-w-[20rem] max-w-xs flex-col items-center justify-center text-center text-sm md:max-w-md md:text-left lg:w-[19%]">
<div className="text-l w-full py-1 text-lg">
<Button
type="submit"
onClick={signupErrorCheck}
size="sm"
isFullWidth
className="h-12"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
isDisabled={isLoading}
>
{" "}
{String(t("signup.signup"))}{" "}
</Button>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1 @@
export { UserInfoSSOStep } from "./UserInfoSSOStep";
@@ -0,0 +1,14 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { SignupSsoPage } from "./SignUpSsoPage";
const SignupSSOPageQueryParamsSchema = z.object({
token: z.string()
});
export const Route = createFileRoute("/_restrict-login-signup/signup/sso")({
component: SignupSsoPage,
validateSearch: zodValidator(SignupSSOPageQueryParamsSchema)
});
@@ -1,16 +1,14 @@
import { FormEvent, useState } from "react";
import Head from "next/head";
import Image from "next/image";
import Link from "next/link";
import { Helmet } from "react-helmet";
import { Link } from "@tanstack/react-router";
import Button from "@app/components/basic/buttons/Button";
import InputField from "@app/components/basic/InputField";
import { EmailServiceSetupModal } from "@app/components/v2";
import { Button, EmailServiceSetupModal } from "@app/components/v2";
import { usePopUp } from "@app/hooks";
import { useSendPasswordResetEmail } from "@app/hooks/api";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
export default function VerifyEmail() {
export const VerifyEmailPage = () => {
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState("");
const [step, setStep] = useState(1);
@@ -43,7 +41,7 @@ export default function VerifyEmail() {
return (
<div className="flex h-screen flex-col justify-start bg-bunker-800 px-6">
<Head>
<Helmet>
<title>Login</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
@@ -52,10 +50,10 @@ export default function VerifyEmail() {
name="og:description"
content="Infisical a simple end-to-end encrypted platform that enables teams to sync and manage their .env files."
/>
</Head>
<Link href="/">
</Helmet>
<Link to="/">
<div className="mb-8 mt-20 flex cursor-pointer justify-center">
<Image src="/images/biglogo.png" height={90} width={120} alt="long logo" />
<img src="/images/biglogo.png" height={90} width={120} alt="long logo" />
</div>
</Link>
{step === 1 && (
@@ -84,19 +82,15 @@ export default function VerifyEmail() {
</div>
<div className="mx-auto mt-4 flex max-h-20 w-full max-w-md flex-col items-center justify-center text-sm md:p-2">
<div className="text-l m-8 mt-6 px-8 py-3 text-lg">
<Button
type="submit"
text="Continue"
size="lg"
onButtonPressed={() => {}}
loading={loading}
/>
<Button type="submit" size="lg" onClick={() => {}} isLoading={loading}>
Continue
</Button>
</div>
</div>
</form>
)}
{step === 2 && (
<div className="h-7/12 mx-auto w-full max-w-md rounded-xl bg-bunker py-4 px-6 pt-8 drop-shadow-xl">
<div className="h-7/12 mx-auto w-full max-w-md rounded-xl bg-bunker px-6 py-4 pt-8 drop-shadow-xl">
<p className="mx-auto mb-6 flex w-max justify-center text-xl font-semibold text-bunker-100 md:text-2xl">
Look for an email in your inbox.
</p>
@@ -114,4 +108,4 @@ export default function VerifyEmail() {
/>
</div>
);
}
};
@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { VerifyEmailPage } from "./VerifyEmailPage";
export const Route = createFileRoute("/_restrict-login-signup/verify-email")({
component: VerifyEmailPage
});
@@ -0,0 +1,176 @@
import { Helmet } from "react-helmet";
import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useParams } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip
} 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 { ProjectType } from "@app/hooks/api/workspace/types";
import { usePopUp } from "@app/hooks/usePopUp";
import { CaInstallCertModal } from "../CertificatesPage/components/CaTab/components/CaInstallCertModal";
import { CaModal } from "../CertificatesPage/components/CaTab/components/CaModal";
import { CertificateTemplatesSection } from "../CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection";
import {
CaCertificatesSection,
CaCrlsSection,
CaDetailsSection,
CaRenewalModal
} from "./components";
const Page = () => {
const navigate = useNavigate();
const params = useParams({
from: ROUTE_PATHS.CertManager.CertAuthDetailsByIDPage.id
});
const caId = params.caId as string;
const { data } = useGetCaById(caId);
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const { mutateAsync: deleteCa } = useDeleteCa();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"ca",
"deleteCa",
"installCaCert",
"renewCa"
] as const);
const onRemoveCaSubmit = async (caIdToDelete: string) => {
try {
if (!currentWorkspace?.slug) return;
await deleteCa({ caId: caIdToDelete, projectSlug: currentWorkspace.slug });
createNotification({
text: "Successfully deleted CA",
type: "success"
});
handlePopUpClose("deleteCa");
navigate({
to: `/${ProjectType.CertificateManager}/$projectId/overview` as const,
params: {
projectId
}
});
} catch {
createNotification({
text: "Failed to delete CA",
type: "error"
});
}
};
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<div className="mx-auto mb-6 w-full max-w-7xl px-6 py-6">
<Button
variant="link"
type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() =>
navigate({
to: `/${ProjectType.CertificateManager}/${projectId}/overview` as const,
params: {
projectId
}
})
}
className="mb-4"
>
Certificate Authorities
</Button>
<div className="mb-4 flex items-center justify-between">
<p className="text-3xl font-semibold text-white">{data.friendlyName}</p>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() =>
handlePopUpOpen("deleteCa", {
caId: data.id,
dn: data.dn
})
}
disabled={!isAllowed}
>
Delete CA
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex">
<div className="mr-4 w-96">
<CaDetailsSection caId={caId} handlePopUpOpen={handlePopUpOpen} />
</div>
<div className="w-full">
<CaCertificatesSection caId={caId} />
<CertificateTemplatesSection caId={caId} />
<CaCrlsSection caId={caId} />
</div>
</div>
</div>
)}
<CaModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CaRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CaInstallCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<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 { caId: string })?.caId)}
/>
</div>
);
};
export const CertAuthDetailsByIDPage = () => {
return (
<>
<Helmet>
<title>Certificate Authority</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<Page />
</>
);
};
@@ -0,0 +1,28 @@
import { CaCertificatesTable } from "./CaCertificatesTable";
type Props = {
caId: string;
};
export const CaCertificatesSection = ({ caId }: Props) => {
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">CA Certificates</h3>
{/* <IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
// handlePopUpOpen("addIdentityToProject");
}}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton> */}
</div>
<div className="py-4">
<CaCertificatesTable caId={caId} />
</div>
</div>
);
};
@@ -0,0 +1,133 @@
import { faCertificate, faEllipsis } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import * as x509 from "@peculiar/x509";
import { format } from "date-fns";
import FileSaver from "file-saver";
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,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useGetCaCerts } from "@app/hooks/api";
type Props = {
caId: string;
};
export const CaCertificatesTable = ({ caId }: Props) => {
const { data: caCerts, isPending } = useGetCaCerts(caId);
const downloadTxtFile = (filename: string, content: string) => {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
FileSaver.saveAs(blob, filename);
};
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>CA Certificate #</Th>
<Th>Not Before</Th>
<Th>Not After</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="ca-certificates" />}
{!isPending &&
caCerts?.map((caCert, index) => {
const isLastItem = index === caCerts.length - 1;
const caCertObj = new x509.X509Certificate(caCert.certificate);
return (
<Tr key={`ca-cert=${caCert.serialNumber}`}>
<Td>
<div className="flex items-center">
CA Certificate {caCert.version}
{isLastItem && (
<Badge variant="success" className="ml-4">
Current
</Badge>
)}
</div>
</Td>
<Td>{format(new Date(caCertObj.notBefore), "yyyy-MM-dd")}</Td>
<Td>{format(new Date(caCertObj.notAfter), "yyyy-MM-dd")}</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
downloadTxtFile("cert.pem", caCert.certificate);
}}
disabled={!isAllowed}
>
Download CA Certificate
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
downloadTxtFile("chain.pem", caCert.certificateChain);
}}
disabled={!isAllowed}
>
Download CA Certificate Chain
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && !caCerts?.length && (
<EmptyState
title="This CA does not have any CA certificates installed"
icon={faCertificate}
/>
)}
</TableContainer>
);
};
@@ -0,0 +1 @@
export { CaCertificatesSection } from "./CaCertificatesSection";
@@ -0,0 +1,20 @@
import { CaCrlsTable } from "./CaCrlsTable";
type Props = {
caId: string;
};
export const CaCrlsSection = ({ caId }: Props) => {
return (
<div className="mt-4 w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">
CA Certificate Revocation Lists (CRLs)
</h3>
</div>
<div className="py-4">
<CaCrlsTable caId={caId} />
</div>
</div>
);
};
@@ -0,0 +1,88 @@
import { faCertificate, faFileDownload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
// import * as x509 from "@peculiar/x509";
// import { format } from "date-fns";
import FileSaver from "file-saver";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useGetCaCrls } from "@app/hooks/api";
type Props = {
caId: string;
};
export const CaCrlsTable = ({ caId }: Props) => {
const { data: caCrls, isPending } = useGetCaCrls(caId);
const downloadTxtFile = (filename: string, content: string) => {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
FileSaver.saveAs(blob, filename);
};
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Distribution Point URL</Th>
{/* <Th>This Update</Th> */}
{/* <Th>Next Update</Th> */}
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="ca-certificates" />}
{!isPending &&
caCrls?.map(({ id, crl }) => {
// const caCrlObj = new x509.X509Crl(crl);
return (
<Tr key={`ca-crl-${id}`}>
<Td>
<div className="flex items-center">
{`${window.origin}/api/v1/pki/crl/${id}`}
</div>
</Td>
{/* <Td>{format(new Date(caCrlObj.thisUpdate), "yyyy-MM-dd")}</Td> */}
{/* <Td>
{caCrlObj.nextUpdate
? format(new Date(caCrlObj.nextUpdate), "yyyy-MM-dd")
: "-"}
</Td> */}
<Td>
<Tooltip content="Download CRL">
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={(e) => {
e.stopPropagation();
downloadTxtFile("crl.pem", crl);
}}
>
<FontAwesomeIcon icon={faFileDownload} />
</IconButton>
</Tooltip>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && !caCrls?.length && (
<EmptyState title="This CA does not have any CRLs" icon={faCertificate} />
)}
</TableContainer>
);
};
@@ -0,0 +1 @@
export { CaCrlsSection } from "./CaCrlsSection";
@@ -0,0 +1,208 @@
import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
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 { useTimedReset } from "@app/hooks";
import { CaStatus, CaType, useGetCaById } from "@app/hooks/api";
import { caStatusToNameMap, caTypeToNameMap } from "@app/hooks/api/ca/constants";
import { certKeyAlgorithmToNameMap } from "@app/hooks/api/certificates/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
caId: string;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["ca", "renewCa", "installCaCert"]>,
data?: object
) => void;
};
export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
const [copyTextParentId, isCopyingParentId, setCopyTextParentId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
const { data: ca } = useGetCaById(caId);
return ca ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">CA Details</h3>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Identity}>
{(isAllowed) => {
return (
<Tooltip content="Edit CA">
<IconButton
isDisabled={!isAllowed}
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("ca", {
caId: ca.id
});
}}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
</Tooltip>
);
}}
</ProjectPermissionCan>
</div>
<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>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">CA ID</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">{ca.id}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextId}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText(ca.id);
setCopyTextId("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</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>
)}
</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>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Status</p>
<p className="text-sm text-mineshaft-300">{caStatusToNameMap[ca.status]}</p>
</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>
</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>
</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") : "-"}
</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") : "-"}
</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>
</div>
{ca.status === CaStatus.ACTIVE && (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => {
return (
<Button
isDisabled={!isAllowed}
className="mt-4 w-full"
colorSchema="primary"
type="submit"
onClick={() => {
if (ca.type === CaType.INTERMEDIATE && !ca.parentCaId) {
// intermediate CA with external parent CA
handlePopUpOpen("installCaCert", {
caId,
isParentCaExternal: true
});
return;
}
handlePopUpOpen("renewCa", {
caId
});
}}
>
Renew CA
</Button>
);
}}
</ProjectPermissionCan>
)}
{ca.status === CaStatus.PENDING_CERTIFICATE && (
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => {
return (
<Button
isDisabled={!isAllowed}
className="mt-4 w-full"
colorSchema="primary"
type="submit"
onClick={() => {
handlePopUpOpen("installCaCert", {
caId
});
}}
>
Install CA Certificate
</Button>
);
}}
</ProjectPermissionCan>
)}
</div>
</div>
) : (
<div />
);
};
@@ -0,0 +1,182 @@
// 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,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
CaRenewalType,
useRenewCa
// useGetCaById,
// CaType,
// CaStatus
} from "@app/hooks/api/ca";
import { UsePopUpState } from "@app/hooks/usePopUp";
const caRenewalTypes = [{ label: "Renew with same key pair", value: CaRenewalType.EXISTING }];
const isValidDate = (dateString: string) => {
const date = new Date(dateString);
return !Number.isNaN(date.getTime());
};
const schema = z
.object({
type: z.enum([CaRenewalType.EXISTING]),
notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" })
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["renewCa"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["renewCa"]>, state?: boolean) => void;
};
export const CaRenewalModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const projectSlug = currentWorkspace?.slug || "";
const popUpData = popUp?.renewCa?.data as {
caId: string;
};
// const { data: ca } = useGetCaById(popUpData?.caId || "");
// const { data: parentCa } = useGetCaById(ca?.parentCaId || "");
const { mutateAsync: renewCa } = useRenewCa();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
// setValue
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
type: CaRenewalType.EXISTING,
notAfter: "" // TODO: consider setting a default value
}
});
// useEffect(() => {
// if (ca && ca.status === CaStatus.ACTIVE) {
// const notBeforeDate = new Date(ca.notBefore as string);
// const notAfterDate = new Date(ca.notAfter as string);
// const newNotAfterDate = new Date(
// notAfterDate.getTime() + notAfterDate.getTime() - notBeforeDate.getTime()
// );
// setValue("notAfter", newNotAfterDate.toISOString().split("T")[0]);
// }
// }, [ca, parentCa]);
const onFormSubmit = async ({ type, notAfter }: FormData) => {
try {
if (!projectSlug || !popUpData.caId) return;
await renewCa({
projectSlug,
caId: popUpData.caId,
notAfter,
type
});
handlePopUpToggle("renewCa", false);
createNotification({
text: "Successfully renewed CA",
type: "success"
});
reset();
} catch (err) {
console.error(err);
}
};
return (
<Modal
isOpen={popUp?.renewCa?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("renewCa", isOpen);
reset();
}}
>
<ModalContent title="Renew CA">
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="type"
defaultValue={CaRenewalType.EXISTING}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="CA Renewal Method"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{caRenewalTypes.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="notAfter"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Valid Until"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="YYYY-MM-DD" />
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Renew
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("renewCa", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,4 @@
export { CaCertificatesSection } from "./CaCertificatesSection/CaCertificatesSection";
export { CaCrlsSection } from "./CaCrlsSection";
export { CaDetailsSection } from "./CaDetailsSection";
export { CaRenewalModal } from "./CaRenewalModal";
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { CertAuthDetailsByIDPage } from "./CertAuthDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/cert-manager/$projectId/_cert-manager-layout/ca/$caId"
)({
component: CertAuthDetailsByIDPage
});
@@ -0,0 +1,67 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { CaTab, CertificatesTab, PkiAlertsTab } from "./components";
enum TabSections {
Ca = "certificate-authorities",
Certificates = "certificates",
Alerting = "alerting"
}
export const CertificatesPage = () => {
const { t } = useTranslation();
return (
<div className="container mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: "Certificates" })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
</Helmet>
<div className="mx-auto mb-6 w-full max-w-7xl px-6 py-6">
<p className="mb-4 mr-4 text-3xl font-semibold text-white">Internal PKI</p>
<Tabs defaultValue={TabSections.Certificates}>
<TabList>
<Tab value={TabSections.Certificates}>Certificates</Tab>
<Tab value={TabSections.Ca}>Certificate Authorities</Tab>
<Tab value={TabSections.Alerting}>Alerting</Tab>
</TabList>
<TabPanel value={TabSections.Certificates}>
<ProjectPermissionCan
renderGuardBanner
passThrough={false}
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.Certificates}
>
<CertificatesTab />
</ProjectPermissionCan>
</TabPanel>
<TabPanel value={TabSections.Ca}>
<ProjectPermissionCan
renderGuardBanner
passThrough={false}
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.CertificateAuthorities}
>
<CaTab />
</ProjectPermissionCan>
</TabPanel>
<TabPanel value={TabSections.Alerting}>
<ProjectPermissionCan
renderGuardBanner
passThrough={false}
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.PkiAlerts}
>
<PkiAlertsTab />
</ProjectPermissionCan>
</TabPanel>
</Tabs>
</div>
</div>
);
};
@@ -0,0 +1,17 @@
import { motion } from "framer-motion";
import { CaSection } from "./components";
export const CaTab = () => {
return (
<motion.div
key="panel-certificate-authorities"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<CaSection />
</motion.div>
);
};
@@ -0,0 +1,34 @@
import { Modal, ModalContent } from "@app/components/v2";
import { useGetCaCert } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { CertificateContent } from "../../CertificatesTab/components/CertificateContent";
type Props = {
popUp: UsePopUpState<["caCert"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["caCert"]>, state?: boolean) => void;
};
export const CaCertModal = ({ popUp, handlePopUpToggle }: Props) => {
const { data } = useGetCaCert((popUp?.caCert?.data as { caId: string })?.caId || "");
return (
<Modal
isOpen={popUp?.caCert?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("caCert", isOpen);
}}
>
<ModalContent title="CA Certificate">
{data ? (
<CertificateContent
serialNumber={data.serialNumber}
certificate={data.certificate}
certificateChain={data.certificateChain}
/>
) : (
<div />
)}
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,75 @@
import { useEffect, useState } from "react";
import { FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { ExternalCaInstallForm } from "./ExternalCaInstallForm";
import { InternalCaInstallForm } from "./InternalCaInstallForm";
type Props = {
popUp: UsePopUpState<["installCaCert"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["installCaCert"]>, state?: boolean) => void;
};
enum ParentCaType {
Internal = "internal",
External = "external"
}
export const CaInstallCertModal = ({ popUp, handlePopUpToggle }: Props) => {
const popupData = popUp?.installCaCert?.data;
const caId = popupData?.caId || "";
const isParentCaExternal = popupData?.isParentCaExternal || false;
const [parentCaType, setParentCaType] = useState<ParentCaType>(ParentCaType.Internal);
useEffect(() => {
if (popupData?.isParentCaExternal) {
setParentCaType(ParentCaType.External);
}
}, [popupData]);
const renderForm = (parentCaTypeInput: ParentCaType) => {
switch (parentCaTypeInput) {
case ParentCaType.Internal:
return <InternalCaInstallForm caId={caId} handlePopUpToggle={handlePopUpToggle} />;
default:
return <ExternalCaInstallForm caId={caId} handlePopUpToggle={handlePopUpToggle} />;
}
};
return (
<Modal
isOpen={popUp?.installCaCert?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("installCaCert", isOpen);
}}
>
<ModalContent
title={`${isParentCaExternal ? "Renew" : "Install"} Intermediate CA certificate`}
>
<FormControl label="Parent CA Type">
<Select
value={parentCaType}
onValueChange={(e) => setParentCaType(e as ParentCaType)}
className="w-full"
isDisabled={isParentCaExternal}
>
<SelectItem
value={ParentCaType.Internal}
key={`parent-ca-type-${ParentCaType.Internal}`}
>
Infisical CA
</SelectItem>
<SelectItem
value={ParentCaType.External}
key={`parent-ca-type-${ParentCaType.External}`}
>
External CA
</SelectItem>
</Select>
</FormControl>
{renderForm(parentCaType)}
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,172 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheck, faCopy, faDownload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import FileSaver from "file-saver";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, IconButton, TextArea, Tooltip } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useTimedReset } from "@app/hooks";
import { useGetCaCsr, useImportCaCertificate } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
certificate: z.string().min(1),
certificateChain: z.string().min(1)
});
export type FormData = z.infer<typeof schema>;
type Props = {
caId: string;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["installCaCert"]>, state?: boolean) => void;
};
export const ExternalCaInstallForm = ({ caId, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const [copyTextCaCsr, isCopyingCaCsr, setCopyTextCaCsr] = useTimedReset<string>({
initialState: "Copy to clipboard"
});
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
const { data: csr } = useGetCaCsr(caId);
const { mutateAsync: importCaCertificate } = useImportCaCertificate();
useEffect(() => {
reset();
}, []);
const onFormSubmit = async ({ certificate, certificateChain }: FormData) => {
try {
if (!csr || !caId || !currentWorkspace?.slug) return;
await importCaCertificate({
caId,
projectSlug: currentWorkspace?.slug,
certificate,
certificateChain
});
reset();
createNotification({
text: "Successfully installed certificate for CA",
type: "success"
});
handlePopUpToggle("installCaCert", false);
} catch {
createNotification({
text: "Failed to install certificate for CA",
type: "error"
});
}
};
const downloadTxtFile = (filename: string, content: string) => {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
FileSaver.saveAs(blob, filename);
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
{csr && (
<>
<div className="my-4 flex items-center justify-between">
<h2>CSR for this CA</h2>
<div className="flex">
<Tooltip content={copyTextCaCsr}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(csr);
setCopyTextCaCsr("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingCaCsr ? faCheck : faCopy} />
</IconButton>
</Tooltip>
<Tooltip content="Download">
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
downloadTxtFile("csr.pem", csr);
}}
>
<FontAwesomeIcon icon={faDownload} />
</IconButton>
</Tooltip>
</div>
</div>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{csr}</p>
</div>
</>
)}
<Controller
control={control}
name="certificate"
render={({ field, fieldState: { error } }) => (
<FormControl label="Certificate Body" errorText={error?.message} isError={Boolean(error)}>
<TextArea
{...field}
placeholder="PEM-encoded certificate..."
reSize="none"
className="h-48"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="certificateChain"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Certificate Chain"
errorText={error?.message}
isError={Boolean(error)}
>
<TextArea
{...field}
placeholder="PEM-encoded certificate chain..."
reSize="none"
className="h-48"
/>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Install
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("installCaCert", false)}
>
Cancel
</Button>
</div>
</form>
);
};
@@ -0,0 +1,236 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
CaStatus,
useGetCaById,
useGetCaCsr,
useImportCaCertificate,
useListWorkspaceCas,
useSignIntermediate
} from "@app/hooks/api";
import { caTypeToNameMap } from "@app/hooks/api/ca/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
const isValidDate = (dateString: string) => {
const date = new Date(dateString);
return !Number.isNaN(date.getTime());
};
const getMiddleDate = (date1: Date, date2: Date) => {
const timestamp1 = date1.getTime();
const timestamp2 = date2.getTime();
const middleTimestamp = (timestamp1 + timestamp2) / 2;
return new Date(middleTimestamp);
};
const schema = z.object({
parentCaId: z.string(),
notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }),
maxPathLength: z.string()
});
export type FormData = z.infer<typeof schema>;
type Props = {
caId: string;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["installCaCert"]>, state?: boolean) => void;
};
export const InternalCaInstallForm = ({ caId, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data: cas } = useListWorkspaceCas({
projectSlug: currentWorkspace?.slug ?? "",
status: CaStatus.ACTIVE
});
const { data: ca } = useGetCaById(caId);
const { data: csr } = useGetCaCsr(caId);
const { mutateAsync: signIntermediate } = useSignIntermediate();
const { mutateAsync: importCaCertificate } = useImportCaCertificate();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting },
setValue,
watch
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
maxPathLength: "0"
}
});
useEffect(() => {
reset();
}, []);
useEffect(() => {
if (cas?.length) {
setValue("parentCaId", cas[0].id);
}
}, [cas, setValue]);
const parentCaId = watch("parentCaId");
const { data: parentCa } = useGetCaById(parentCaId);
useEffect(() => {
if (parentCa?.maxPathLength) {
setValue(
"maxPathLength",
(parentCa.maxPathLength === -1 ? 3 : parentCa.maxPathLength - 1).toString()
);
}
if (parentCa?.notAfter) {
const parentCaNotAfter = new Date(parentCa.notAfter);
const middleDate = getMiddleDate(new Date(), parentCaNotAfter);
setValue("notAfter", format(middleDate, "yyyy-MM-dd"));
}
}, [parentCa]);
const onFormSubmit = async ({ notAfter, maxPathLength }: FormData) => {
try {
if (!csr || !caId || !currentWorkspace?.slug) return;
const { certificate, certificateChain } = await signIntermediate({
caId: parentCaId,
csr,
maxPathLength: Number(maxPathLength),
notAfter,
notBefore: new Date().toISOString()
});
await importCaCertificate({
caId,
projectSlug: currentWorkspace?.slug,
certificate,
certificateChain
});
reset();
createNotification({
text: "Successfully installed certificate for CA",
type: "success"
});
handlePopUpToggle("installCaCert", false);
} catch {
createNotification({
text: "Failed to install certificate for CA",
type: "error"
});
}
};
function generatePathLengthOpts(parentCaMaxPathLength: number): number[] {
if (parentCaMaxPathLength === -1) {
return [-1, 0, 1, 2, 3];
}
return Array.from({ length: parentCaMaxPathLength }, (_, index) => index);
}
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="parentCaId"
// defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Parent CA"
errorText={error?.message}
isError={Boolean(error)}
isRequired
>
<Select
// defaultValue={field.value}
{...field}
onValueChange={onChange}
className="w-full"
>
{(cas || [])
.filter((c) => {
const isParentCaNotSelf = c.id !== ca?.id;
const isParentCaActive = c.status === CaStatus.ACTIVE;
const isParentCaAllowedChildrenCas = c.maxPathLength && c.maxPathLength !== 0;
return isParentCaNotSelf && isParentCaActive && isParentCaAllowedChildrenCas;
})
.map(({ id, type, dn }) => (
<SelectItem value={id} key={`parent-ca-${id}`}>
{`${caTypeToNameMap[type]}: ${dn}`}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="notAfter"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Valid Until"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="YYYY-MM-DD" />
</FormControl>
)}
/>
<Controller
control={control}
name="maxPathLength"
// defaultValue="0"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Path Length" errorText={error?.message} isError={Boolean(error)}>
<Select
// defaultValue={field.value}
{...field}
onValueChange={onChange}
className="w-full"
>
{generatePathLengthOpts(parentCa?.maxPathLength || 0).map((value) => (
<SelectItem value={String(value)} key={`ca-path-length-${value}`}>
{`${value}`}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Install
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("installCaCert", false)}
>
Cancel
</Button>
</div>
</form>
);
};
@@ -0,0 +1 @@
export { CaInstallCertModal } from "./CaInstallCertModal";
@@ -0,0 +1,471 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Switch
// DatePicker
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { CaType, useCreateCa, useGetCaById, 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";
const isValidDate = (dateString: string) => {
const date = new Date(dateString);
return !Number.isNaN(date.getTime());
};
const getDateTenYearsFromToday = () => {
const date = new Date();
date.setFullYear(date.getFullYear() + 10);
return format(date, "yyyy-MM-dd");
};
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()
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["ca"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["ca"]>, state?: boolean) => void;
};
const caTypes = [
{ label: "Root", value: CaType.ROOT },
{ label: "Intermediate", value: CaType.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 { mutateAsync: createMutateAsync } = useCreateCa();
const { mutateAsync: updateMutateAsync } = useUpdateCa();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting },
watch
} = 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: "",
organization: "",
ou: "",
country: "",
province: "",
locality: "",
commonName: "",
notAfter: getDateTenYearsFromToday(),
maxPathLength: "-1",
keyAlgorithm: CertKeyAlgorithm.RSA_2048,
requireTemplateForIssuance: true
});
}
}, [ca]);
const onFormSubmit = async ({
type,
friendlyName,
commonName,
organization,
ou,
country,
locality,
province,
notAfter,
maxPathLength,
keyAlgorithm,
requireTemplateForIssuance
}: FormData) => {
try {
if (!currentWorkspace?.slug) return;
if (ca) {
// update
await updateMutateAsync({
projectSlug: currentWorkspace.slug,
caId: ca.id,
requireTemplateForIssuance
});
} else {
// create
await createMutateAsync({
projectSlug: currentWorkspace.slug,
type,
friendlyName,
commonName,
organization,
ou,
country,
province,
locality,
notAfter,
maxPathLength: Number(maxPathLength),
keyAlgorithm,
requireTemplateForIssuance
});
}
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"} Private 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.ROOT}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="CA 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>
)}
/>
{caType === CaType.ROOT && (
<>
{/* <Controller
name="notAfter"
control={control}
defaultValue={getDefaultNotAfterDate()}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl
label="Validity"
errorText={error?.message}
isError={Boolean(error)}
className="mr-4"
>
<DatePicker
value={field.value || undefined}
onChange={(date) => {
onChange(date);
setIsStartDatePickerOpen(false);
}}
popUpProps={{
open: isStartDatePickerOpen,
onOpenChange: setIsStartDatePickerOpen
}}
popUpContentProps={{}}
/>
</FormControl>
);
}}
/> */}
<Controller
control={control}
defaultValue=""
name="notAfter"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Valid Until"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="YYYY-MM-DD" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
<Controller
control={control}
name="maxPathLength"
defaultValue="-1"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Path Length"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(ca)}
>
{[-1, 0, 1, 2, 3, 4].map((value) => (
<SelectItem value={String(value)} key={`ca-path-length-${value}`}>
{`${value}`}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</>
)}
<Controller
control={control}
name="keyAlgorithm"
defaultValue={CertKeyAlgorithm.RSA_2048}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Key Algorithm"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(ca)}
>
{certKeyAlgorithms.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</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 CA" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="organization"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Organization (O)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Acme Corp" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="ou"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Organization Unit (OU)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Engineering" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="country"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Country Name (C)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="United States (US)" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="province"
render={({ field, fieldState: { error } }) => (
<FormControl
label="State or Province Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="California" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="locality"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Locality Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="San Francisco" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="commonName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Common Name (CN)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Example CA" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
<Controller
control={control}
name="requireTemplateForIssuance"
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">Require Template for Certificate 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,132 @@
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, useDeleteCa, useUpdateCa } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { CaCertModal } from "./CaCertModal";
import { CaInstallCertModal } from "./CaInstallCertModal";
import { CaModal } from "./CaModal";
import { CaTable } from "./CaTable";
export const CaSection = () => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync: deleteCa } = useDeleteCa();
const { mutateAsync: updateCa } = useUpdateCa();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"ca",
"caCert",
"installCaCert",
"deleteCa",
"caStatus", // enable / disable
"upgradePlan"
] as const);
const onRemoveCaSubmit = async (caId: string) => {
try {
if (!currentWorkspace?.slug) return;
await deleteCa({ caId, projectSlug: currentWorkspace.slug });
createNotification({
text: "Successfully deleted CA",
type: "success"
});
handlePopUpClose("deleteCa");
} catch {
createNotification({
text: "Failed to delete CA",
type: "error"
});
}
};
const onUpdateCaStatus = async ({ caId, status }: { caId: string; status: CaStatus }) => {
try {
if (!currentWorkspace?.slug) return;
await updateCa({ caId, projectSlug: currentWorkspace.slug, status });
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 ? "enabled" : "disabled"} 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">Certificate Authorities</p>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("ca")}
isDisabled={!isAllowed}
>
Create CA
</Button>
)}
</ProjectPermissionCan>
</div>
<CaModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CaInstallCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CaCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CaTable 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 { caId: string })?.caId)}
/>
<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)}
deleteKey="confirm"
onDeleteApproved={() =>
onUpdateCaStatus(popUp?.caStatus?.data as { caId: string; status: CaStatus })
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
);
};
@@ -0,0 +1,227 @@
import { faBan, faCertificate, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { format } from "date-fns";
import { 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, useListWorkspaceCas } from "@app/hooks/api";
import {
caStatusToNameMap,
caTypeToNameMap,
getCaStatusBadgeVariant
} from "@app/hooks/api/ca/constants";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<
["installCaCert", "caCert", "ca", "deleteCa", "caStatus", "upgradePlan"]
>,
data?: {
caId?: string;
dn?: string;
status?: CaStatus;
description?: string;
}
) => void;
};
export const CaTable = ({ handlePopUpOpen }: Props) => {
const navigate = useNavigate();
const { currentWorkspace } = useWorkspace();
const { data, isPending } = useListWorkspaceCas({
projectSlug: currentWorkspace?.slug ?? ""
});
return (
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Friendly Name</Th>
<Th>Status</Th>
<Th>Type</Th>
<Th>Valid Until</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={() =>
navigate({
to: `/${ProjectType.CertificateManager}/$projectId/ca/$caId` as const,
params: {
projectId: currentWorkspace.id,
caId: ca.id
}
})
}
>
<Td>{ca.friendlyName}</Td>
<Td>
<Badge variant={getCaStatusBadgeVariant(ca.status)}>
{caStatusToNameMap[ca.status]}
</Badge>
</Td>
<Td>{caTypeToNameMap[ca.type]}</Td>
<Td>
<div className="flex items-center">
<p>{ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "-"}</p>
</div>
</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">
{ca.status === CaStatus.PENDING_CERTIFICATE && (
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("installCaCert", {
caId: ca.id
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faCertificate} />}
>
Install CA Certificate
</DropdownMenuItem>
)}
</ProjectPermissionCan>
)}
{ca.status !== CaStatus.PENDING_CERTIFICATE && (
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("caCert", {
caId: ca.id
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faCertificate} />}
>
View Certificate
</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", {
caId: ca.id,
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", {
caId: ca.id,
dn: ca.dn
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete CA
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && data?.length === 0 && (
<EmptyState title="No certificate authorities have been created" icon={faCertificate} />
)}
</TableContainer>
</div>
);
};
@@ -0,0 +1 @@
export { CaSection } from "./CaSection";
@@ -0,0 +1 @@
export { CaTab } from "./CaTab";
@@ -0,0 +1,21 @@
import { motion } from "framer-motion";
import { PkiCollectionSection } from "../PkiAlertsTab/components";
// import { CertificateTemplatesSection } from "./components/CertificateTemplatesSection";
import { CertificatesSection } from "./components";
export const CertificatesTab = () => {
return (
<motion.div
key="panel-certificates"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<PkiCollectionSection />
{/* <CertificateTemplatesSection /> */}
<CertificatesSection />
</motion.div>
);
};
@@ -0,0 +1,37 @@
import { Modal, ModalContent } from "@app/components/v2";
import { useGetCertBody } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { CertificateContent } from "./CertificateContent";
type Props = {
popUp: UsePopUpState<["certificateCert"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["certificateCert"]>, state?: boolean) => void;
};
export const CertificateCertModal = ({ popUp, handlePopUpToggle }: Props) => {
const { data } = useGetCertBody(
(popUp?.certificateCert?.data as { serialNumber: string })?.serialNumber || ""
);
return (
<Modal
isOpen={popUp?.certificateCert?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("certificateCert", isOpen);
}}
>
<ModalContent title="Export Certificate">
{data ? (
<CertificateContent
serialNumber={data.serialNumber}
certificate={data.certificate}
certificateChain={data.certificateChain}
/>
) : (
<div />
)}
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,173 @@
import { faCheck, faCopy, faDownload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import FileSaver from "file-saver";
import { IconButton, Tooltip } from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
type Props = {
serialNumber: string;
certificate: string;
certificateChain: string;
privateKey?: string;
};
export const CertificateContent = ({
serialNumber,
certificate,
certificateChain,
privateKey
}: Props) => {
const [copyTextSerialNumber, isCopyingSerialNumber, setCopyTextSerialNumber] =
useTimedReset<string>({
initialState: "Copy to clipboard"
});
const [copyTextCertificate, isCopyingCertificate, setCopyTextCertificate] = useTimedReset<string>(
{
initialState: "Copy to clipboard"
}
);
const [copyTextCertificateChain, isCopyingCertificateChain, setCopyTextCertificateChain] =
useTimedReset<string>({
initialState: "Copy to clipboard"
});
const [copyTextCertificateSk, isCopyingCertificateSk, setCopyTextCertificateSk] =
useTimedReset<string>({
initialState: "Copy to clipboard"
});
const downloadTxtFile = (filename: string, content: string) => {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
FileSaver.saveAs(blob, filename);
};
return (
<div>
<h2 className="mb-4">Serial Number</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{serialNumber}</p>
<Tooltip content={copyTextSerialNumber}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(serialNumber);
setCopyTextSerialNumber("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingSerialNumber ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
<div className="mb-4 flex items-center justify-between">
<h2>Certificate Body</h2>
<div className="flex">
<Tooltip content={copyTextCertificate}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(certificate);
setCopyTextCertificate("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingCertificate ? faCheck : faCopy} />
</IconButton>
</Tooltip>
<Tooltip content="Download">
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
downloadTxtFile("cert.pem", certificate);
}}
>
<FontAwesomeIcon icon={faDownload} />
</IconButton>
</Tooltip>
</div>
</div>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{certificate}</p>
</div>
{certificateChain && (
<>
<div className="mb-4 flex items-center justify-between">
<h2>Certificate Chain</h2>
<div className="flex">
<Tooltip content={copyTextCertificateChain}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(certificateChain);
setCopyTextCertificateChain("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingCertificateChain ? faCheck : faCopy} />
</IconButton>
</Tooltip>
<Tooltip content="Download">
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
downloadTxtFile("chain.pem", certificateChain);
}}
>
<FontAwesomeIcon icon={faDownload} />
</IconButton>
</Tooltip>
</div>
</div>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{certificateChain}</p>
</div>
</>
)}
{privateKey && (
<>
<div className="mb-4 flex items-center justify-between">
<h2>Certificate Private Key</h2>
<div className="flex">
<Tooltip content={copyTextCertificateSk}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(privateKey);
setCopyTextCertificateSk("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingCertificateSk ? faCheck : faCopy} />
</IconButton>
</Tooltip>
<Tooltip content={copyTextCertificateSk}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
downloadTxtFile("private_key.txt", privateKey);
}}
>
<FontAwesomeIcon icon={faDownload} />
</IconButton>
</Tooltip>
</div>
</div>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{privateKey}</p>
</div>
</>
)}
</div>
);
};
@@ -0,0 +1,538 @@
import { useEffect, useState } from "react";
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,
FormControl,
FormLabel,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Tooltip
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
CaStatus,
useCreateCertificate,
useGetCert,
useGetCertTemplate,
useListWorkspaceCas,
useListWorkspaceCertificateTemplates,
useListWorkspacePkiCollections
} from "@app/hooks/api";
import { caTypeToNameMap } from "@app/hooks/api/ca/constants";
import {
EXTENDED_KEY_USAGES_OPTIONS,
KEY_USAGES_OPTIONS
} from "@app/hooks/api/certificates/constants";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { CertificateContent } from "./CertificateContent";
const schema = z.object({
certificateTemplateId: z.string().optional(),
caId: z.string(),
collectionId: z.string().optional(),
friendlyName: z.string(),
commonName: z.string().trim().min(1),
altNames: z.string(),
ttl: z.string().trim(),
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 = {
popUp: UsePopUpState<["certificate"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["certificate"]>, state?: boolean) => void;
};
type TCertificateDetails = {
serialNumber: string;
certificate: string;
certificateChain: string;
privateKey: string;
};
const CERT_TEMPLATE_NONE_VALUE = "none";
export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
const [certificateDetails, setCertificateDetails] = useState<TCertificateDetails | null>(null);
const { currentWorkspace } = useWorkspace();
const { data: cert } = useGetCert(
(popUp?.certificate?.data as { serialNumber: string })?.serialNumber || ""
);
const { data: cas } = useListWorkspaceCas({
projectSlug: currentWorkspace?.slug ?? "",
status: CaStatus.ACTIVE
});
const { data } = useListWorkspacePkiCollections({
workspaceId: currentWorkspace?.id || ""
});
const { data: templatesData } = useListWorkspaceCertificateTemplates({
workspaceId: currentWorkspace?.id || ""
});
const { mutateAsync: createCertificate } = useCreateCertificate();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting },
setValue,
watch
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
keyUsages: {
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
[CertKeyUsage.KEY_ENCIPHERMENT]: true
},
extendedKeyUsages: {}
}
});
const selectedCertTemplateId = watch("certificateTemplateId");
const hasCertTemplateSelected =
selectedCertTemplateId !== "" && selectedCertTemplateId !== CERT_TEMPLATE_NONE_VALUE;
const { data: selectedCertTemplate } = useGetCertTemplate(
hasCertTemplateSelected ? (selectedCertTemplateId as string) : ""
);
useEffect(() => {
if (cert) {
reset({
caId: cert.caId,
friendlyName: cert.friendlyName,
commonName: cert.commonName,
altNames: cert.altNames,
certificateTemplateId: cert.certificateTemplateId ?? CERT_TEMPLATE_NONE_VALUE,
ttl: "",
keyUsages: Object.fromEntries((cert.keyUsages || []).map((name) => [name, true])),
extendedKeyUsages: Object.fromEntries(
(cert.extendedKeyUsages || []).map((name) => [name, true])
)
});
} else {
reset({
caId: "",
friendlyName: "",
commonName: "",
altNames: "",
ttl: "",
certificateTemplateId: CERT_TEMPLATE_NONE_VALUE,
keyUsages: {
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
[CertKeyUsage.KEY_ENCIPHERMENT]: true
},
extendedKeyUsages: {}
});
}
}, [cert]);
useEffect(() => {
if (!cert && selectedCertTemplate) {
setValue("ttl", selectedCertTemplate.ttl);
setValue(
"keyUsages",
Object.fromEntries(selectedCertTemplate.keyUsages.map((name) => [name, true]))
);
setValue(
"extendedKeyUsages",
Object.fromEntries(selectedCertTemplate.extendedKeyUsages.map((name) => [name, true]))
);
}
}, [selectedCertTemplate, cert]);
const onFormSubmit = async ({
caId,
friendlyName,
collectionId,
commonName,
altNames,
ttl,
keyUsages,
extendedKeyUsages
}: FormData) => {
try {
if (!currentWorkspace?.slug) return;
const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate({
caId: !selectedCertTemplate ? caId : undefined,
certificateTemplateId: selectedCertTemplate ? selectedCertTemplateId : undefined,
projectSlug: currentWorkspace.slug,
pkiCollectionId: collectionId,
friendlyName,
commonName,
altNames,
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)
});
reset();
setCertificateDetails({
serialNumber,
certificate,
certificateChain,
privateKey
});
createNotification({
text: "Successfully created certificate",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create certificate",
type: "error"
});
}
};
useEffect(() => {
if (cas?.length) {
setValue("caId", cas[0].id);
}
}, [cas]);
return (
<Modal
isOpen={popUp?.certificate?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("certificate", isOpen);
reset();
setCertificateDetails(null);
}}
>
<ModalContent title={`${cert ? "View" : "Issue"} Certificate`}>
{!certificateDetails ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="certificateTemplateId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label={
<div>
<FormLabel
isRequired
label="Certificate Template"
icon={
<Tooltip
className="text-center"
content={
<span>
When a template is selected, the details provided are validated
against the template policies.
</span>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
</Tooltip>
}
/>
</div>
}
errorText={error?.message}
isError={Boolean(error)}
isRequired
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(cert)}
>
<SelectItem value={CERT_TEMPLATE_NONE_VALUE} key="cert-template-none">
None
</SelectItem>
{(templatesData?.certificateTemplates || []).map(({ id, name }) => (
<SelectItem value={id} key={`cert-template-${id}`}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{(!selectedCertTemplateId ||
selectedCertTemplateId === CERT_TEMPLATE_NONE_VALUE ||
cert) && (
<>
<Controller
control={control}
name="caId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Issuing CA"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
isRequired
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(cert)}
>
{(cas || []).map(({ id, type, dn }) => (
<SelectItem value={id} key={`ca-${id}`}>
{`${caTypeToNameMap[type]}: ${dn}`}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="collectionId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Certificate Collection (Optional)"
errorText={error?.message}
isError={Boolean(error)}
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="commonName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Common Name (CN)"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="service.acme.com" isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="altNames"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Alternative Names (SANs)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="app1.acme.com, app2.acme.com, ..."
isDisabled={Boolean(cert)}
/>
</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, ..."
isDisabled={Boolean(cert)}
/>
</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"
isDisabled={Boolean(cert)}
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"
isDisabled={Boolean(cert)}
isChecked={value[optionValue]}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</div>
</FormControl>
);
}}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
{!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("certificate", false)}
>
Cancel
</Button>
</div>
)}
</form>
) : (
<CertificateContent
serialNumber={certificateDetails.serialNumber}
certificate={certificateDetails.certificate}
certificateChain={certificateDetails.certificateChain}
privateKey={certificateDetails.privateKey}
/>
)}
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,131 @@
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, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useRevokeCert } from "@app/hooks/api";
import { crlReasons } from "@app/hooks/api/certificates/constants";
import { CrlReason } from "@app/hooks/api/certificates/enums";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
revocationReason: z.enum([
CrlReason.UNSPECIFIED,
CrlReason.KEY_COMPROMISE,
CrlReason.CA_COMPROMISE,
CrlReason.AFFILIATION_CHANGED,
CrlReason.SUPERSEDED,
CrlReason.CESSATION_OF_OPERATION,
CrlReason.CERTIFICATE_HOLD,
CrlReason.PRIVILEGE_WITHDRAWN,
CrlReason.A_A_COMPROMISE
])
});
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["revokeCertificate"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["revokeCertificate"]>,
state?: boolean
) => void;
};
export const CertificateRevocationModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync: revokeCertificate } = useRevokeCert();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
const onFormSubmit = async ({ revocationReason }: FormData) => {
try {
if (!currentWorkspace?.slug) return;
const { serialNumber } = popUp.revokeCertificate.data as { serialNumber: string };
await revokeCertificate({
projectSlug: currentWorkspace.slug,
serialNumber,
revocationReason
});
reset();
handlePopUpToggle("revokeCertificate", false);
createNotification({
text: "Successfully revoked certificate",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to revoke certificate",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.revokeCertificate?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("revokeCertificate", isOpen);
reset();
}}
>
<ModalContent title="Revoke Certificate">
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="revocationReason"
defaultValue={CrlReason.UNSPECIFIED}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Revocation Reason"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{crlReasons.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Revoke
</Button>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,261 @@
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,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Switch,
TextArea
} from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { useCreateEstConfig, useGetEstConfig, useUpdateEstConfig } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
enum EnrollmentMethod {
EST = "est"
}
type Props = {
popUp: UsePopUpState<["enrollmentOptions"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["enrollmentOptions"]>,
state?: boolean
) => void;
};
const schema = z.object({
method: z.nativeEnum(EnrollmentMethod),
caChain: z.string().optional(),
passphrase: z.string().optional(),
isEnabled: z.boolean(),
disableBootstrapCertValidation: z.boolean().optional().default(false)
});
export type FormData = z.infer<typeof schema>;
export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }: Props) => {
const popUpData = popUp?.enrollmentOptions?.data as {
id: string;
};
const certificateTemplateId = popUpData?.id;
const { data } = useGetEstConfig(certificateTemplateId);
const {
control,
handleSubmit,
reset,
setError,
watch,
setValue,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
const { mutateAsync: createEstConfig } = useCreateEstConfig();
const { mutateAsync: updateEstConfig } = useUpdateEstConfig();
const [isPassphraseFocused, setIsPassphraseFocused] = useToggle(false);
const disableBootstrapCertValidation = watch("disableBootstrapCertValidation");
useEffect(() => {
if (disableBootstrapCertValidation) {
setValue("caChain", "");
}
}, [disableBootstrapCertValidation]);
useEffect(() => {
if (data) {
reset({
caChain: data.caChain,
isEnabled: data.isEnabled,
disableBootstrapCertValidation: data.disableBootstrapCertValidation
});
} else {
reset({
caChain: "",
isEnabled: false,
disableBootstrapCertValidation: false
});
}
}, [data]);
const onFormSubmit = async ({ caChain, passphrase, isEnabled }: FormData) => {
try {
if (data) {
await updateEstConfig({
certificateTemplateId,
caChain,
passphrase,
isEnabled,
disableBootstrapCertValidation
});
} else {
if (!passphrase) {
setError("passphrase", { message: "Passphrase is required to setup EST" });
return;
}
await createEstConfig({
certificateTemplateId,
caChain,
passphrase,
isEnabled,
disableBootstrapCertValidation
});
}
handlePopUpToggle("enrollmentOptions", false);
createNotification({
text: "Successfully saved changes",
type: "success"
});
reset();
} catch (err) {
console.error(err);
}
};
return (
<Modal
isOpen={popUp?.enrollmentOptions?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("enrollmentOptions", isOpen);
reset();
}}
>
<ModalContent title="Manage Enrollment Options">
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="method"
defaultValue={EnrollmentMethod.EST}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Client Enrollment Method"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
<SelectItem value={EnrollmentMethod.EST} key={EnrollmentMethod.EST}>
EST
</SelectItem>
</Select>
</FormControl>
)}
/>
{data && (
<FormControl label="EST Label">
<Input value={data.certificateTemplateId} isDisabled className="bg-white/[0.07]" />
</FormControl>
)}
<Controller
control={control}
name="disableBootstrapCertValidation"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="skip-bootstrap-cert-validation"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="ml-1 w-full">Disable Bootstrap Certificate Validation</p>
</Switch>
</FormControl>
);
}}
/>
{!disableBootstrapCertValidation && (
<Controller
control={control}
name="caChain"
disabled={disableBootstrapCertValidation}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Certificate Authority Chain"
isError={Boolean(error)}
errorText={error?.message}
isRequired={!disableBootstrapCertValidation}
>
<TextArea
{...field}
isDisabled={disableBootstrapCertValidation}
className="min-h-[15rem] border-none bg-mineshaft-900 text-gray-400"
reSize="none"
/>
</FormControl>
)}
/>
)}
<Controller
control={control}
name="passphrase"
render={({ field, fieldState: { error } }) => (
<FormControl label="Passphrase" isError={Boolean(error)} errorText={error?.message}>
<Input
{...field}
type={isPassphraseFocused ? "text" : "password"}
onFocus={() => setIsPassphraseFocused.on()}
onBlur={() => setIsPassphraseFocused.off()}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="isEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="is-active"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="ml-1 w-full">EST Enabled</p>
</Switch>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Save
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("enrollmentOptions", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,493 @@
import { useEffect } from "react";
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,
FormControl,
FormLabel,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Tooltip
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
CaStatus,
useCreateCertTemplate,
useGetCaById,
useGetCertTemplate,
useListWorkspaceCas,
useListWorkspacePkiCollections,
useUpdateCertTemplate
} from "@app/hooks/api";
import { caTypeToNameMap } from "@app/hooks/api/ca/constants";
import {
EXTENDED_KEY_USAGES_OPTIONS,
KEY_USAGES_OPTIONS
} from "@app/hooks/api/certificates/constants";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
import { UsePopUpState } from "@app/hooks/usePopUp";
const validateTemplateRegexField = z
.string()
.trim()
.min(1)
.max(100)
.regex(/^[a-zA-Z0-9 *@\-\\.\\]+$/, {
message:
"Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed."
});
const schema = z.object({
caId: z.string(),
collectionId: z.string().optional(),
name: z.string().min(1),
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 = {
caId: string;
popUp: UsePopUpState<["certificateTemplate"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["certificateTemplate"]>,
state?: boolean
) => void;
};
export const CertificateTemplateModal = ({ popUp, handlePopUpToggle, caId }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data: ca } = useGetCaById(caId);
const { data: certTemplate } = useGetCertTemplate(
(popUp?.certificateTemplate?.data as { id: string })?.id || ""
);
const { data: cas } = useListWorkspaceCas({
projectSlug: currentWorkspace?.slug ?? "",
status: CaStatus.ACTIVE
});
const { data: collectionsData } = useListWorkspacePkiCollections({
workspaceId: currentWorkspace?.id || ""
});
const { mutateAsync: createCertTemplate } = useCreateCertTemplate();
const { mutateAsync: updateCertTemplate } = useUpdateCertTemplate();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
keyUsages: {
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
[CertKeyUsage.KEY_ENCIPHERMENT]: true
}
}
});
useEffect(() => {
if (certTemplate) {
reset({
caId: certTemplate.caId,
name: certTemplate.name,
commonName: certTemplate.commonName,
subjectAlternativeName: certTemplate.subjectAlternativeName,
collectionId: certTemplate.pkiCollectionId ?? undefined,
ttl: certTemplate.ttl,
keyUsages: Object.fromEntries(certTemplate.keyUsages.map((name) => [name, true]) ?? []),
extendedKeyUsages: Object.fromEntries(
certTemplate.extendedKeyUsages.map((name) => [name, true]) ?? []
)
});
} else {
reset({
caId,
name: "",
commonName: "",
ttl: "",
keyUsages: {
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
[CertKeyUsage.KEY_ENCIPHERMENT]: true
},
extendedKeyUsages: {}
});
}
}, [certTemplate, ca]);
const onFormSubmit = async ({
collectionId,
name,
commonName,
subjectAlternativeName,
ttl,
keyUsages,
extendedKeyUsages
}: FormData) => {
if (!currentWorkspace?.id) {
return;
}
try {
if (certTemplate) {
await updateCertTemplate({
id: certTemplate.id,
projectId: currentWorkspace.id,
pkiCollectionId: collectionId,
caId,
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,
pkiCollectionId: collectionId,
caId,
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("certificateTemplate", false);
} catch (err) {
console.error(err);
createNotification({
text: "Failed to save changes",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.certificateTemplate?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("certificateTemplate", isOpen);
reset();
}}
>
<ModalContent title={certTemplate ? "Certificate Template" : "Create Certificate Template"}>
<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 Certificate Template" />
</FormControl>
)}
/>
<Controller
control={control}
name="caId"
defaultValue={caId}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Issuing CA"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
isRequired
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled
>
{(cas || []).map(({ id, type, dn }) => (
<SelectItem value={id} key={`ca-${id}`}>
{`${caTypeToNameMap[type]}: ${dn}`}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="collectionId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Certificate Collection (Optional)"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(collectionsData?.collections || []).map(({ id, name }) => (
<SelectItem value={id} key={`pki-collection-${id}`}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<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("certificateTemplate", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,107 @@
/**
* TODO (dangtony98): Reevaluate if this component should be in main
* CertificateTab or under CA page in the future.
*/
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 { DeleteActionModal, IconButton } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteCertTemplate } from "@app/hooks/api";
import { CertificateTemplateEnrollmentModal } from "./CertificateTemplateEnrollmentModal";
import { CertificateTemplateModal } from "./CertificateTemplateModal";
import { CertificateTemplatesTable } from "./CertificateTemplatesTable";
type Props = {
caId: string;
};
export const CertificateTemplatesSection = ({ caId }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"certificateTemplate",
"deleteCertificateTemplate",
"enrollmentOptions",
"upgradePlan"
] as const);
const { currentWorkspace } = useWorkspace();
const { mutateAsync: deleteCertTemplate } = useDeleteCertTemplate();
const onRemoveCertificateTemplateSubmit = async (id: string) => {
if (!currentWorkspace?.id) {
return;
}
try {
await deleteCertTemplate({
id,
projectId: currentWorkspace.id
});
createNotification({
text: "Successfully deleted certificate template",
type: "success"
});
handlePopUpClose("deleteCertificateTemplate");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete certificate template",
type: "error"
});
}
};
return (
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Certificate Templates</h3>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.CertificateTemplates}
>
{(isAllowed) => (
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => handlePopUpOpen("certificateTemplate")}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
<div className="py-4">
<CertificateTemplatesTable handlePopUpOpen={handlePopUpOpen} caId={caId} />
</div>
<CertificateTemplateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} caId={caId} />
<CertificateTemplateEnrollmentModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteCertificateTemplate.isOpen}
title={`Are you sure want to delete the certificate template ${
(popUp?.deleteCertificateTemplate?.data as { name: string })?.name || ""
} from the project?`}
onChange={(isOpen) => handlePopUpToggle("deleteCertificateTemplate", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveCertificateTemplateSubmit(
(popUp?.deleteCertificateTemplate?.data as { id: string })?.id
)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="Managing template enrollment options for EST is only available on Infisical's Enterprise plan."
/>
</div>
);
};
@@ -0,0 +1,146 @@
import { faEllipsis, faFileAlt, faTrash, faUserPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
import { useGetCaCertTemplates } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
caId: string;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<
["certificateTemplate", "deleteCertificateTemplate", "enrollmentOptions", "upgradePlan"]
>,
data?: {
id?: string;
name?: string;
}
) => void;
};
export const CertificateTemplatesTable = ({ handlePopUpOpen, caId }: Props) => {
const { subscription } = useSubscription();
const { data, isPending } = useGetCaCertTemplates(caId);
return (
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={2} innerKey="project-cas" />}
{!isPending &&
data?.certificateTemplates.map((certificateTemplate) => {
return (
<Tr className="h-10" key={`certificate-${certificateTemplate.id}`}>
<Td>{certificateTemplate.name}</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="sm" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuItem
onClick={() =>
handlePopUpOpen("certificateTemplate", {
id: certificateTemplate.id
})
}
icon={<FontAwesomeIcon icon={faFileAlt} size="sm" className="mr-1" />}
>
Manage Policies
</DropdownMenuItem>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.CertificateTemplates}
>
{(isAllowed) => (
<DropdownMenuItem
onClick={() => {
if (!subscription?.pkiEst) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("enrollmentOptions", {
id: certificateTemplate.id
});
}}
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faUserPlus} size="sm" />}
>
Manage Enrollment
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.CertificateTemplates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} size="sm" className="mr-1" />}
onClick={() =>
handlePopUpOpen("deleteCertificateTemplate", {
id: certificateTemplate.id,
name: certificateTemplate.name
})
}
>
Delete Template
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && !data?.certificateTemplates?.length && (
<EmptyState
title="No certificate templates have been created for this CA"
icon={faFileAlt}
/>
)}
</TableContainer>
</div>
);
};
@@ -0,0 +1,88 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
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 { useDeleteCert } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { CertificateCertModal } from "./CertificateCertModal";
import { CertificateModal } from "./CertificateModal";
import { CertificateRevocationModal } from "./CertificateRevocationModal";
import { CertificatesTable } from "./CertificatesTable";
export const CertificatesSection = () => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync: deleteCert } = useDeleteCert();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"certificate",
"certificateCert",
"deleteCertificate",
"revokeCertificate"
] as const);
const onRemoveCertificateSubmit = async (serialNumber: string) => {
try {
if (!currentWorkspace?.slug) return;
await deleteCert({ serialNumber, projectSlug: currentWorkspace.slug });
createNotification({
text: "Successfully deleted certificate",
type: "success"
});
handlePopUpClose("deleteCertificate");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete certificate",
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">Certificates</p>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificate")}
isDisabled={!isAllowed}
>
Issue
</Button>
)}
</ProjectPermissionCan>
</div>
<CertificatesTable handlePopUpOpen={handlePopUpOpen} />
<CertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteCertificate.isOpen}
title={`Are you sure want to remove the certificate ${
(popUp?.deleteCertificate?.data as { commonName: string })?.commonName || ""
} from the project?`}
onChange={(isOpen) => handlePopUpToggle("deleteCertificate", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveCertificateSubmit(
(popUp?.deleteCertificate?.data as { serialNumber: string })?.serialNumber
)
}
/>
</div>
);
};
@@ -0,0 +1,219 @@
import { useState } from "react";
import {
faBan,
faCertificate,
faEllipsis,
faEye,
faFileExport,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useListWorkspaceCertificates } from "@app/hooks/api";
import { CertStatus } from "@app/hooks/api/certificates/enums";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { getCertValidUntilBadgeDetails } from "./CertificatesTable.utils";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<
["certificate", "deleteCertificate", "revokeCertificate", "certificateCert"]
>,
data?: {
serialNumber?: string;
commonName?: string;
}
) => void;
};
const PER_PAGE_INIT = 25;
export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(PER_PAGE_INIT);
const { currentWorkspace } = useWorkspace();
const { data, isPending } = useListWorkspaceCertificates({
projectSlug: currentWorkspace?.slug ?? "",
offset: (page - 1) * perPage,
limit: perPage
});
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Friendly Name</Th>
<Th>Status</Th>
<Th>Not Before</Th>
<Th>Not After</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="project-cas" />}
{!isPending &&
data?.certificates.map((certificate) => {
const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter);
return (
<Tr className="h-10" key={`certificate-${certificate.id}`}>
<Td>{certificate.friendlyName}</Td>
<Td>
{certificate.status === CertStatus.REVOKED ? (
<Badge variant="danger">Revoked</Badge>
) : (
<Badge variant={variant}>{label}</Badge>
)}
</Td>
<Td>
{certificate.notBefore
? format(new Date(certificate.notBefore), "yyyy-MM-dd")
: "-"}
</Td>
<Td>
{certificate.notAfter
? format(new Date(certificate.notAfter), "yyyy-MM-dd")
: "-"}
</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.Read}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("certificateCert", {
serialNumber: certificate.serialNumber
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faFileExport} />}
>
Export Certificate
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("certificate", {
serialNumber: certificate.serialNumber
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEye} />}
>
View Details
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("revokeCertificate", {
serialNumber: certificate.serialNumber
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faBan} />}
>
Revoke Certificate
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("deleteCertificate", {
serialNumber: certificate.serialNumber,
commonName: certificate.commonName
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Certificate
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</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)}
/>
)}
{!isPending && !data?.certificates?.length && (
<EmptyState title="No certificates have been issued" icon={faCertificate} />
)}
</TableContainer>
);
};
@@ -0,0 +1,28 @@
import ms from "ms";
export const getCertValidUntilBadgeDetails = (notAfter: string) => {
const currentDate = new Date().getTime();
const notAfterDate = new Date(notAfter).getTime();
const diffInMs = notAfterDate - currentDate;
let variant: "success" | "primary" | "danger" = "success";
let label = "Healthy";
if (diffInMs > ms("60d")) {
variant = "success";
} else if (diffInMs > ms("30d")) {
variant = "primary";
} else {
variant = "danger";
}
if (diffInMs > ms("60d")) {
label = "Healthy";
} else if (diffInMs > ms("0d")) {
label = `Expires in ${ms(diffInMs)}`;
} else {
label = "Expired";
}
return { variant, label };
};
@@ -0,0 +1 @@
export { CertificatesSection } from "./CertificatesSection";
@@ -0,0 +1 @@
export { CertificatesTab } from "./CertificatesTab";

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