mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-20 21:18:18 +00:00
Merge branch 'main' into ENG-3723
This commit is contained in:
@@ -117,12 +117,28 @@ export const SelectOrganizationSection = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const { token, isMfaEnabled, mfaMethod } = await selectOrg
|
||||
.mutateAsync({
|
||||
let token;
|
||||
let isMfaEnabled;
|
||||
let mfaMethod;
|
||||
|
||||
try {
|
||||
const result = await selectOrg.mutateAsync({
|
||||
organizationId: organization.id,
|
||||
userAgent: callbackPort ? UserAgentType.CLI : undefined
|
||||
})
|
||||
.finally(() => setIsInitialOrgCheckLoading(false));
|
||||
});
|
||||
token = result.token;
|
||||
isMfaEnabled = result.isMfaEnabled;
|
||||
mfaMethod = result.mfaMethod;
|
||||
} catch (error: any) {
|
||||
setIsInitialOrgCheckLoading(false);
|
||||
if (error?.response?.status === 403) {
|
||||
await handleLogout();
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
setIsInitialOrgCheckLoading(false);
|
||||
}
|
||||
|
||||
await router.invalidate();
|
||||
|
||||
|
||||
+13
-2
@@ -47,6 +47,7 @@ import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm";
|
||||
import { VercelConnectionForm } from "./VercelConnectionForm";
|
||||
import { WindmillConnectionForm } from "./WindmillConnectionForm";
|
||||
import { ZabbixConnectionForm } from "./ZabbixConnectionForm";
|
||||
import { RedisConnectionForm } from "./RedisConnectionForm";
|
||||
|
||||
type FormProps = {
|
||||
onComplete: (appConnection: TAppConnection) => void;
|
||||
@@ -142,7 +143,7 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => {
|
||||
case AppConnection.OnePass:
|
||||
return <OnePassConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.Heroku:
|
||||
return <HerokuConnectionForm onSubmit={onSubmit} />;
|
||||
return <HerokuConnectionForm onSubmit={onSubmit} projectId={projectId} />;
|
||||
case AppConnection.Render:
|
||||
return <RenderConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.Flyio:
|
||||
@@ -167,6 +168,8 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => {
|
||||
return <NetlifyConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.Okta:
|
||||
return <OktaConnectionForm onSubmit={onSubmit} />;
|
||||
case AppConnection.Redis:
|
||||
return <RedisConnectionForm onSubmit={onSubmit} />;
|
||||
default:
|
||||
throw new Error(`Unhandled App ${app}`);
|
||||
}
|
||||
@@ -285,7 +288,13 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
|
||||
case AppConnection.OnePass:
|
||||
return <OnePassConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
case AppConnection.Heroku:
|
||||
return <HerokuConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
return (
|
||||
<HerokuConnectionForm
|
||||
onSubmit={onSubmit}
|
||||
appConnection={appConnection}
|
||||
projectId={appConnection.projectId}
|
||||
/>
|
||||
);
|
||||
case AppConnection.Render:
|
||||
return <RenderConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
case AppConnection.Flyio:
|
||||
@@ -314,6 +323,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
|
||||
return <DigitalOceanConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
case AppConnection.Okta:
|
||||
return <OktaConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
case AppConnection.Redis:
|
||||
return <RedisConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
|
||||
default:
|
||||
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
|
||||
}
|
||||
|
||||
+2
-2
@@ -39,7 +39,7 @@ import {
|
||||
} from "@app/hooks/api/appConnections";
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
|
||||
import { GithubFormData } from "../../../OauthCallbackPage/OauthCallbackPage.types";
|
||||
import { GitHubFormData } from "../../../OauthCallbackPage/OauthCallbackPage.types";
|
||||
import {
|
||||
genericAppConnectionFieldsSchema,
|
||||
GenericAppConnectionsFields
|
||||
@@ -118,7 +118,7 @@ export const GitHubConnectionForm = ({ appConnection, projectId }: Props) => {
|
||||
connectionId: appConnection?.id,
|
||||
projectId,
|
||||
returnUrl
|
||||
} as GithubFormData)
|
||||
} as GitHubFormData)
|
||||
);
|
||||
|
||||
const githubHost =
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ import {
|
||||
} from "@app/hooks/api/appConnections";
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
|
||||
import { GithubRadarFormData } from "../../../OauthCallbackPage/OauthCallbackPage.types";
|
||||
import { GitHubRadarFormData } from "../../../OauthCallbackPage/OauthCallbackPage.types";
|
||||
import {
|
||||
genericAppConnectionFieldsSchema,
|
||||
GenericAppConnectionsFields
|
||||
@@ -76,7 +76,7 @@ export const GitHubRadarConnectionForm = ({ appConnection, projectId }: Props) =
|
||||
connectionId: appConnection?.id,
|
||||
projectId,
|
||||
returnUrl
|
||||
} as GithubRadarFormData)
|
||||
} as GitHubRadarFormData)
|
||||
);
|
||||
|
||||
switch (formData.method) {
|
||||
|
||||
+11
-3
@@ -15,7 +15,11 @@ import {
|
||||
Select,
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
|
||||
import {
|
||||
APP_CONNECTION_MAP,
|
||||
getAppConnectionMethodDetails,
|
||||
useGetAppConnectionOauthReturnUrl
|
||||
} from "@app/helpers/appConnections";
|
||||
import { isInfisicalCloud } from "@app/helpers/platform";
|
||||
import { useGetAppConnectionOption } from "@app/hooks/api/appConnections";
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
@@ -32,6 +36,7 @@ import {
|
||||
type Props = {
|
||||
appConnection?: THerokuConnection;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
projectId: string | undefined | null;
|
||||
};
|
||||
|
||||
const formSchema = z.discriminatedUnion("method", [
|
||||
@@ -53,10 +58,12 @@ const formSchema = z.discriminatedUnion("method", [
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const HerokuConnectionForm = ({ appConnection, onSubmit: formSubmit }: Props) => {
|
||||
export const HerokuConnectionForm = ({ appConnection, onSubmit: formSubmit, projectId }: Props) => {
|
||||
const isUpdate = Boolean(appConnection);
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
|
||||
const returnUrl = useGetAppConnectionOauthReturnUrl();
|
||||
|
||||
const {
|
||||
option: { oauthClientId },
|
||||
isLoading
|
||||
@@ -110,7 +117,8 @@ export const HerokuConnectionForm = ({ appConnection, onSubmit: formSubmit }: Pr
|
||||
JSON.stringify({
|
||||
...formData,
|
||||
connectionId: appConnection?.id,
|
||||
isUpdate
|
||||
returnUrl,
|
||||
projectId
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Tab } from "@headlessui/react";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
ModalClose,
|
||||
SecretInput,
|
||||
Select,
|
||||
SelectItem,
|
||||
Switch,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
|
||||
import { RedisConnectionMethod, TRedisConnection } from "@app/hooks/api/appConnections";
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
|
||||
import {
|
||||
genericAppConnectionFieldsSchema,
|
||||
GenericAppConnectionsFields
|
||||
} from "./GenericAppConnectionFields";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
type Props = {
|
||||
appConnection?: TRedisConnection;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const rootSchema = genericAppConnectionFieldsSchema.extend({
|
||||
app: z.literal(AppConnection.Redis)
|
||||
});
|
||||
|
||||
const formSchema = z.discriminatedUnion("method", [
|
||||
rootSchema.extend({
|
||||
method: z.literal(RedisConnectionMethod.UsernameAndPassword),
|
||||
credentials: z.object({
|
||||
host: z.string().trim().min(1, "Host required"),
|
||||
port: z.coerce.number().default(6379),
|
||||
username: z.string().trim().min(1, "Username required"),
|
||||
password: z.string().trim().optional(),
|
||||
sslEnabled: z.boolean().default(false),
|
||||
sslRejectUnauthorized: z.boolean().default(true),
|
||||
sslCertificate: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => value || undefined)
|
||||
.optional()
|
||||
})
|
||||
})
|
||||
]);
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const RedisConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(appConnection);
|
||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: appConnection ?? {
|
||||
app: AppConnection.Redis,
|
||||
method: RedisConnectionMethod.UsernameAndPassword,
|
||||
credentials: {
|
||||
host: "",
|
||||
port: 6379,
|
||||
username: "",
|
||||
password: "",
|
||||
sslEnabled: false,
|
||||
sslRejectUnauthorized: true,
|
||||
sslCertificate: undefined
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
control,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
const sslEnabled = watch("credentials.sslEnabled");
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{!isUpdate && <GenericAppConnectionsFields />}
|
||||
<Controller
|
||||
name="method"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText={`The method you would like to use to connect with ${
|
||||
APP_CONNECTION_MAP[AppConnection.Redis].name
|
||||
}. This field cannot be changed after creation.`}
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Method"
|
||||
>
|
||||
<Select
|
||||
isDisabled={isUpdate}
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
position="popper"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
>
|
||||
{Object.values(RedisConnectionMethod).map((method) => {
|
||||
return (
|
||||
<SelectItem value={method} key={method}>
|
||||
{getAppConnectionMethodDetails(method).name}{" "}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<>
|
||||
<Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}>
|
||||
<Tab.List className="-pb-1 mb-6 w-full border-b-2 border-mineshaft-600">
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
|
||||
selected
|
||||
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
|
||||
: "text-bunker-300"
|
||||
}`
|
||||
}
|
||||
>
|
||||
Configuration
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
|
||||
selected
|
||||
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
|
||||
: "text-bunker-300"
|
||||
}`
|
||||
}
|
||||
>
|
||||
SSL ({sslEnabled ? "Enabled" : "Disabled"})
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels className="mb-4 rounded border border-mineshaft-600 bg-mineshaft-700/70 p-3 pb-0">
|
||||
<Tab.Panel>
|
||||
<div className="mt-[0.675rem] flex items-start gap-2">
|
||||
<Controller
|
||||
name="credentials.host"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="flex-1"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Host"
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.port"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="w-28"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Port"
|
||||
>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-[0.675rem] flex items-start gap-2">
|
||||
<Controller
|
||||
name="credentials.username"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Username"
|
||||
className="flex-1"
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.password"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Password"
|
||||
className="flex-1"
|
||||
>
|
||||
<SecretInput
|
||||
containerClassName="text-gray-400 w-full group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<Controller
|
||||
name="credentials.sslEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
|
||||
<Switch
|
||||
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
|
||||
id="ssl-enabled"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={value}
|
||||
onCheckedChange={onChange}
|
||||
>
|
||||
Enable SSL
|
||||
</Switch>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.sslCertificate"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
className={sslEnabled ? "" : "opacity-50"}
|
||||
label="SSL Certificate"
|
||||
isOptional
|
||||
>
|
||||
<TextArea
|
||||
className="h-[3.5rem] !resize-none"
|
||||
{...field}
|
||||
isDisabled={!sslEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.sslRejectUnauthorized"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className={sslEnabled ? "" : "opacity-50"}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Switch
|
||||
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
|
||||
id="ssl-reject-unauthorized"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={sslEnabled ? value : false}
|
||||
onCheckedChange={onChange}
|
||||
isDisabled={!sslEnabled}
|
||||
>
|
||||
<p className="w-[9.5rem]">
|
||||
Reject Unauthorized
|
||||
<Tooltip
|
||||
className="max-w-md"
|
||||
content={
|
||||
<p>
|
||||
If enabled, Infisical will only connect to the server if it has a
|
||||
valid, trusted SSL certificate.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
|
||||
</Tooltip>
|
||||
</p>
|
||||
</Switch>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</>
|
||||
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isUpdate ? "Update Credentials" : "Connect to Database"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
+66
-7
@@ -12,6 +12,7 @@ import {
|
||||
AzureKeyVaultConnectionMethod,
|
||||
GitHubConnectionMethod,
|
||||
GitLabConnectionMethod,
|
||||
HerokuConnectionMethod,
|
||||
TAppConnection,
|
||||
useCreateAppConnection,
|
||||
useUpdateAppConnection
|
||||
@@ -28,7 +29,8 @@ const formDataStorageFieldMap: Partial<Record<AppConnection, string>> = {
|
||||
[AppConnection.AzureKeyVault]: "azureKeyVaultConnectionFormData",
|
||||
[AppConnection.AzureAppConfiguration]: "azureAppConfigurationConnectionFormData",
|
||||
[AppConnection.AzureClientSecrets]: "azureClientSecretsConnectionFormData",
|
||||
[AppConnection.AzureDevOps]: "azureDevOpsConnectionFormData"
|
||||
[AppConnection.AzureDevOps]: "azureDevOpsConnectionFormData",
|
||||
[AppConnection.Heroku]: "herokuConnectionFormData"
|
||||
};
|
||||
|
||||
export const OAuthCallbackPage = () => {
|
||||
@@ -83,7 +85,7 @@ export const OAuthCallbackPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitlab = useCallback(async () => {
|
||||
const handleGitLab = useCallback(async () => {
|
||||
const formData = getFormData(AppConnection.GitLab);
|
||||
if (formData === null) return null;
|
||||
|
||||
@@ -375,7 +377,7 @@ export const OAuthCallbackPage = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleGithub = useCallback(async () => {
|
||||
const handleGitHub = useCallback(async () => {
|
||||
const formData = getFormData(AppConnection.GitHub);
|
||||
if (formData === null) return null;
|
||||
|
||||
@@ -463,7 +465,7 @@ export const OAuthCallbackPage = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleGithubRadar = useCallback(async () => {
|
||||
const handleGitHubRadar = useCallback(async () => {
|
||||
const formData = getFormData(AppConnection.GitHubRadar);
|
||||
if (formData === null) return null;
|
||||
|
||||
@@ -520,6 +522,61 @@ export const OAuthCallbackPage = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleHeroku = useCallback(async () => {
|
||||
const formData = getFormData(AppConnection.Heroku);
|
||||
if (formData === null) return null;
|
||||
|
||||
clearState(AppConnection.Heroku);
|
||||
|
||||
const { connectionId, name, description, returnUrl, projectId } = formData;
|
||||
|
||||
let connection: TAppConnection;
|
||||
|
||||
try {
|
||||
if (connectionId) {
|
||||
connection = await updateAppConnection.mutateAsync({
|
||||
app: AppConnection.Heroku,
|
||||
connectionId,
|
||||
credentials: {
|
||||
code: code as string
|
||||
}
|
||||
});
|
||||
} else {
|
||||
connection = await createAppConnection.mutateAsync({
|
||||
app: AppConnection.Heroku,
|
||||
name,
|
||||
description,
|
||||
method: HerokuConnectionMethod.OAuth,
|
||||
projectId,
|
||||
credentials: {
|
||||
code: code as string
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
createNotification({
|
||||
title: `Failed to ${connectionId ? "update" : "add"} Heroku Connection`,
|
||||
text: e.message,
|
||||
type: "error"
|
||||
});
|
||||
navigate({
|
||||
to: returnUrl,
|
||||
params: {
|
||||
projectId
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
connectionId,
|
||||
returnUrl,
|
||||
appConnectionName: formData.app,
|
||||
projectId,
|
||||
connection
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Ensure that the localstorage is ready for use, to avoid the form data being malformed
|
||||
useEffect(() => {
|
||||
if (!isReady) {
|
||||
@@ -540,11 +597,11 @@ export const OAuthCallbackPage = () => {
|
||||
} | null = null;
|
||||
|
||||
if (appConnection === AppConnection.GitHub) {
|
||||
data = await handleGithub();
|
||||
data = await handleGitHub();
|
||||
} else if (appConnection === AppConnection.GitHubRadar) {
|
||||
data = await handleGithubRadar();
|
||||
data = await handleGitHubRadar();
|
||||
} else if (appConnection === AppConnection.GitLab) {
|
||||
data = await handleGitlab();
|
||||
data = await handleGitLab();
|
||||
} else if (appConnection === AppConnection.AzureKeyVault) {
|
||||
data = await handleAzureKeyVault();
|
||||
} else if (appConnection === AppConnection.AzureAppConfiguration) {
|
||||
@@ -553,6 +610,8 @@ export const OAuthCallbackPage = () => {
|
||||
data = await handleAzureClientSecrets();
|
||||
} else if (appConnection === AppConnection.AzureDevOps) {
|
||||
data = await handleAzureDevOps();
|
||||
} else if (appConnection === AppConnection.Heroku) {
|
||||
data = await handleHeroku();
|
||||
}
|
||||
|
||||
if (data) {
|
||||
|
||||
+12
-5
@@ -6,7 +6,8 @@ import {
|
||||
TAzureKeyVaultConnection,
|
||||
TGitHubConnection,
|
||||
TGitHubRadarConnection,
|
||||
TGitLabConnection
|
||||
TGitLabConnection,
|
||||
THerokuConnection
|
||||
} from "@app/hooks/api/appConnections";
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
|
||||
@@ -17,10 +18,10 @@ type BaseFormData = {
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type GithubFormData = BaseFormData &
|
||||
export type GitHubFormData = BaseFormData &
|
||||
Pick<TGitHubConnection, "name" | "method" | "description" | "gatewayId" | "credentials">;
|
||||
|
||||
export type GithubRadarFormData = BaseFormData &
|
||||
export type GitHubRadarFormData = BaseFormData &
|
||||
Pick<TGitHubRadarConnection, "name" | "method" | "description">;
|
||||
|
||||
export type GitLabFormData = BaseFormData &
|
||||
@@ -51,9 +52,12 @@ export type AzureDevOpsFormData = BaseFormData &
|
||||
Pick<TAzureDevOpsConnection, "name" | "method" | "description"> &
|
||||
(Pick<OAuthCredentials, "tenantId" | "orgName"> | Pick<AccessTokenCredentials, "orgName">);
|
||||
|
||||
export type HerokuFormData = BaseFormData &
|
||||
Pick<THerokuConnection, "name" | "method" | "description">;
|
||||
|
||||
export type FormDataMap = {
|
||||
[AppConnection.GitHub]: GithubFormData & { app: AppConnection.GitHub };
|
||||
[AppConnection.GitHubRadar]: GithubRadarFormData & { app: AppConnection.GitHubRadar };
|
||||
[AppConnection.GitHub]: GitHubFormData & { app: AppConnection.GitHub };
|
||||
[AppConnection.GitHubRadar]: GitHubRadarFormData & { app: AppConnection.GitHubRadar };
|
||||
[AppConnection.GitLab]: GitLabFormData & { app: AppConnection.GitLab };
|
||||
[AppConnection.AzureKeyVault]: AzureKeyVaultFormData & { app: AppConnection.AzureKeyVault };
|
||||
[AppConnection.AzureAppConfiguration]: AzureAppConfigurationFormData & {
|
||||
@@ -65,4 +69,7 @@ export type FormDataMap = {
|
||||
[AppConnection.AzureDevOps]: AzureDevOpsFormData & {
|
||||
app: AppConnection.AzureDevOps;
|
||||
};
|
||||
[AppConnection.Heroku]: HerokuFormData & {
|
||||
app: AppConnection.Heroku;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { SingleValue } from "react-select";
|
||||
import { faExternalLink } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FilterableSelect, FormControl } from "@app/components/v2";
|
||||
|
||||
import {
|
||||
useCalculateUpgradePath,
|
||||
useGetUpgradePathVersions
|
||||
} from "../../../hooks/api/upgradePath/queries";
|
||||
|
||||
type VersionOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
isLatest: boolean;
|
||||
};
|
||||
|
||||
const formatVersionOption = (option: VersionOption) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{option.label}</span>
|
||||
{option.isLatest && <span className="text-xs text-primary">(Latest)</span>}
|
||||
</div>
|
||||
);
|
||||
interface UpgradeResult {
|
||||
path: Array<{
|
||||
version: string;
|
||||
name: string;
|
||||
publishedAt: string;
|
||||
prerelease: boolean;
|
||||
}>;
|
||||
breakingChanges: Array<{
|
||||
version: string;
|
||||
changes: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
action: string;
|
||||
}>;
|
||||
}>;
|
||||
features: Array<{
|
||||
version: string;
|
||||
name: string;
|
||||
body: string;
|
||||
publishedAt: string;
|
||||
}>;
|
||||
hasDbMigration: boolean;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const UpgradePathPage = () => {
|
||||
const [fromVersion, setFromVersion] = useState<string | null>(null);
|
||||
const [toVersion, setToVersion] = useState<string | null>(null);
|
||||
const [upgradeResult, setUpgradeResult] = useState<UpgradeResult | null>(null);
|
||||
|
||||
const {
|
||||
data: versions,
|
||||
isLoading: versionsLoading,
|
||||
isFetching: versionsFetching
|
||||
} = useGetUpgradePathVersions({
|
||||
enabled: true,
|
||||
staleTime: 24 * 60 * 60 * 1000,
|
||||
refetchOnWindowFocus: false
|
||||
});
|
||||
|
||||
const calculateMutation = useCalculateUpgradePath();
|
||||
|
||||
// Handle mutation results
|
||||
React.useEffect(() => {
|
||||
if (calculateMutation.isSuccess && calculateMutation.data) {
|
||||
setUpgradeResult(calculateMutation.data);
|
||||
}
|
||||
}, [calculateMutation.isSuccess, calculateMutation.data]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (calculateMutation.isError) {
|
||||
createNotification({
|
||||
text:
|
||||
(calculateMutation.error as any)?.response?.data?.message ||
|
||||
"Failed to calculate upgrade path",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}, [calculateMutation.isError, calculateMutation.error]);
|
||||
|
||||
const versionOptions = useMemo(() => {
|
||||
if (!versions?.versions) return [];
|
||||
|
||||
return versions.versions
|
||||
.filter((version) => !version.tagName.includes("nightly"))
|
||||
.map((version) => ({
|
||||
label: version.tagName,
|
||||
value: version.tagName,
|
||||
isLatest: versions.versions[0]?.tagName === version.tagName
|
||||
}));
|
||||
}, [versions?.versions]);
|
||||
|
||||
const handleFromVersionSelect = (value: unknown) => {
|
||||
const selected = value as SingleValue<VersionOption>;
|
||||
setFromVersion(selected?.value || null);
|
||||
};
|
||||
|
||||
const handleToVersionSelect = (value: unknown) => {
|
||||
const selected = value as SingleValue<VersionOption>;
|
||||
setToVersion(selected?.value || null);
|
||||
};
|
||||
|
||||
const handleCalculate = () => {
|
||||
if (!fromVersion || !toVersion) {
|
||||
createNotification({
|
||||
text: "Please select both from and to versions",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromVersion === toVersion) {
|
||||
createNotification({
|
||||
text: "From and To versions cannot be the same",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
calculateMutation.mutate({
|
||||
fromVersion,
|
||||
toVersion
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Infisical Upgrade Path Tool | Infisical</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
<meta property="og:image" content="/images/message.png" />
|
||||
<meta property="og:title" content="Infisical Upgrade Path Tool" />
|
||||
<meta
|
||||
name="og:description"
|
||||
content="Plan your Infisical upgrade path safely and efficiently."
|
||||
/>
|
||||
</Helmet>
|
||||
<div className="dark h-full">
|
||||
<div className="flex h-screen flex-col justify-between overflow-auto bg-mineshaft-900 text-bunker-200 dark:[color-scheme:dark]">
|
||||
<div />
|
||||
<div className="mx-auto w-full max-w-4xl px-4 py-4 md:px-0">
|
||||
{/* Header */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mb-4 flex justify-center pt-8">
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://infisical.com">
|
||||
<img
|
||||
src="/images/gradientLogo.svg"
|
||||
height={90}
|
||||
width={120}
|
||||
alt="Infisical logo"
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<h1 className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-4xl font-medium text-transparent">
|
||||
Upgrade your Infisical Version
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Calculator Card */}
|
||||
<div className="mb-8 rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-6">
|
||||
<h2 className="mb-6 text-xl font-semibold text-bunker-200">Calculate Upgrade Path</h2>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* From Version Selector */}
|
||||
<FormControl label="From Version" isRequired>
|
||||
<FilterableSelect
|
||||
options={versionOptions}
|
||||
value={versionOptions.find((opt) => opt.value === fromVersion) || null}
|
||||
onChange={handleFromVersionSelect}
|
||||
placeholder="Search or select version..."
|
||||
isLoading={versionsLoading || versionsFetching}
|
||||
isDisabled={versionsLoading || versionsFetching}
|
||||
isSearchable
|
||||
isClearable
|
||||
menuPortalTarget={document.body}
|
||||
formatOptionLabel={formatVersionOption}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* To Version Selector */}
|
||||
<FormControl label="To Version" isRequired>
|
||||
<FilterableSelect
|
||||
options={versionOptions}
|
||||
value={versionOptions.find((opt) => opt.value === toVersion) || null}
|
||||
onChange={handleToVersionSelect}
|
||||
placeholder="Search or select version..."
|
||||
isLoading={versionsLoading || versionsFetching}
|
||||
isDisabled={versionsLoading || versionsFetching}
|
||||
isSearchable
|
||||
isClearable
|
||||
menuPortalTarget={document.body}
|
||||
formatOptionLabel={formatVersionOption}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleCalculate}
|
||||
isLoading={calculateMutation.isPending}
|
||||
className="w-full bg-primary font-medium text-black hover:bg-primary/80"
|
||||
>
|
||||
{calculateMutation.isPending ? "Calculating..." : "Calculate Upgrade Path"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Section */}
|
||||
{upgradeResult && (
|
||||
<div className="space-y-6">
|
||||
{/* Action Required Banner */}
|
||||
{(() => {
|
||||
const versionsWithBreakingChanges = upgradeResult.breakingChanges
|
||||
.filter((bc) => bc.changes.length > 0)
|
||||
.map((bc) => bc.version);
|
||||
|
||||
const versionsWithDbMigrations = upgradeResult.path
|
||||
.filter((step, index) => {
|
||||
const isStartingVersion = index === 0;
|
||||
if (isStartingVersion) return false;
|
||||
|
||||
const versionConfig = upgradeResult.config as Record<string, any>;
|
||||
|
||||
const possibleKeys = [
|
||||
step.version,
|
||||
step.version.replace(/^v/, ""),
|
||||
step.version.replace(/^infisical\/v?/, ""),
|
||||
step.version.replace(/^infisical\/v?/, "").replace(/-[a-zA-Z]+$/, "")
|
||||
];
|
||||
|
||||
const dbSchemaChanges = possibleKeys
|
||||
.map((key) => versionConfig?.[key]?.db_schema_changes)
|
||||
.find((changes) => changes);
|
||||
|
||||
return (
|
||||
dbSchemaChanges &&
|
||||
(typeof dbSchemaChanges === "string"
|
||||
? dbSchemaChanges.trim()
|
||||
: dbSchemaChanges)
|
||||
);
|
||||
})
|
||||
.map((step) => step.version);
|
||||
|
||||
const allConflictVersions = [
|
||||
...new Set([...versionsWithBreakingChanges, ...versionsWithDbMigrations])
|
||||
];
|
||||
const hasIssues = allConflictVersions.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border p-4 ${
|
||||
hasIssues
|
||||
? "border-yellow-500/20 bg-yellow-500/10"
|
||||
: "border-green-500/20 bg-green-500/10"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
{hasIssues ? (
|
||||
<svg
|
||||
className="h-5 w-5 text-yellow-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="h-5 w-5 text-green-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3
|
||||
className={`font-medium ${
|
||||
hasIssues ? "text-yellow-400" : "text-green-400"
|
||||
}`}
|
||||
>
|
||||
{hasIssues ? "Action Required:" : "Ready to Upgrade:"}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-bunker-300">
|
||||
{hasIssues
|
||||
? `Your upgrade path contains conflicts in the following versions: ${allConflictVersions.join(", ")}. Please review and resolve each item before proceeding to the next version.`
|
||||
: "Your upgrade path is clear with no breaking changes or conflicts. You can proceed with the upgrade."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Upgrade Steps */}
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-6">
|
||||
<div className="mb-6 flex items-center space-x-2">
|
||||
<svg
|
||||
className="h-5 w-5 text-bunker-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
/>
|
||||
</svg>
|
||||
<h2 className="text-xl font-semibold text-bunker-200">Upgrade Steps</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{(() => {
|
||||
const pathSteps = upgradeResult.path.map((step) => ({
|
||||
...step,
|
||||
hasGithubRelease: true
|
||||
}));
|
||||
|
||||
const breakingChangeSteps = upgradeResult.breakingChanges
|
||||
.filter(
|
||||
(bc) =>
|
||||
!upgradeResult.path.some((step) => {
|
||||
const normalizeVersion = (v: string) =>
|
||||
v.replace(/^(infisical\/)?v?/, "").replace(/-[a-zA-Z]+$/, "");
|
||||
return (
|
||||
normalizeVersion(step.version) === normalizeVersion(bc.version)
|
||||
);
|
||||
})
|
||||
)
|
||||
.map((bc) => ({
|
||||
version: bc.version,
|
||||
name: bc.version,
|
||||
publishedAt: new Date().toISOString(),
|
||||
prerelease: false,
|
||||
hasGithubRelease: false
|
||||
}));
|
||||
|
||||
const allSteps = [...pathSteps, ...breakingChangeSteps];
|
||||
|
||||
allSteps.sort((a, b) => {
|
||||
const normalizeForSort = (v: string) => {
|
||||
const cleaned = v
|
||||
.replace(/^(infisical\/)?v?/, "")
|
||||
.replace(/-[a-zA-Z]+$/, "");
|
||||
const parts = cleaned.split(".").map(Number);
|
||||
return parts[0] * 1000000 + (parts[1] || 0) * 1000 + (parts[2] || 0);
|
||||
};
|
||||
return normalizeForSort(a.version) - normalizeForSort(b.version);
|
||||
});
|
||||
|
||||
return allSteps;
|
||||
})().map((step, index, allSteps) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === allSteps.length - 1;
|
||||
|
||||
const versionChanges = upgradeResult.breakingChanges.find((bc) => {
|
||||
if (bc.version === step.version) return true;
|
||||
|
||||
const normalizeVersion = (v: string) => {
|
||||
return v.replace(/^(infisical\/)?v?/, "").replace(/-[a-zA-Z]+$/, "");
|
||||
};
|
||||
|
||||
const normalizedStep = normalizeVersion(step.version);
|
||||
const normalizedBC = normalizeVersion(bc.version);
|
||||
|
||||
return normalizedStep === normalizedBC;
|
||||
});
|
||||
|
||||
const versionConfig = upgradeResult.config as Record<string, any>;
|
||||
|
||||
const possibleKeys = [
|
||||
step.version,
|
||||
step.version.replace(/^v/, ""),
|
||||
step.version.replace(/^infisical\/v?/, ""),
|
||||
step.version.replace(/^infisical\/v?/, "").replace(/-[a-zA-Z]+$/, "")
|
||||
];
|
||||
|
||||
const dbMigrationDescription = possibleKeys
|
||||
.map((key) => versionConfig?.[key]?.db_schema_changes)
|
||||
.find((changes) => changes);
|
||||
|
||||
const hasDbMigration =
|
||||
!isFirst &&
|
||||
dbMigrationDescription &&
|
||||
(typeof dbMigrationDescription === "string"
|
||||
? dbMigrationDescription.trim()
|
||||
: dbMigrationDescription);
|
||||
|
||||
const hasBreakingChanges =
|
||||
versionChanges && versionChanges.changes.length > 0;
|
||||
|
||||
return (
|
||||
<div key={step.version} className="relative flex">
|
||||
{/* Timeline Column */}
|
||||
<div className="mr-4 flex flex-col items-center">
|
||||
<div className="z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 border-primary bg-primary text-black">
|
||||
<span className="text-xs font-bold">{index + 1}</span>
|
||||
</div>
|
||||
|
||||
{/* Timeline Line */}
|
||||
{!isLast && (
|
||||
<div className="-mb-4 -mt-4 h-full w-px flex-1 bg-mineshaft-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Column */}
|
||||
<div className="flex-1 pb-6">
|
||||
{/* Version Header */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="font-medium text-bunker-200">{step.version}</h3>
|
||||
{isFirst && (
|
||||
<span className="rounded border border-primary/30 bg-primary/20 px-2 py-1 text-xs font-medium text-primary">
|
||||
Starting Version
|
||||
</span>
|
||||
)}
|
||||
{isLast && (
|
||||
<span className="rounded border border-primary/30 bg-primary/20 px-2 py-1 text-xs font-medium text-primary">
|
||||
Target Version
|
||||
</span>
|
||||
)}
|
||||
{step.hasGithubRelease ? (
|
||||
<a
|
||||
href={`https://github.com/Infisical/infisical/releases/tag/${step.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center space-x-1 text-xs text-primary transition-colors hover:text-primary/80"
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLink} className="h-3 w-3" />
|
||||
<span>View Changelog</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="inline-flex items-center space-x-1 text-xs text-bunker-400">
|
||||
<span>No GitHub Release</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Version Notes */}
|
||||
{(() => {
|
||||
if (isFirst) return null;
|
||||
|
||||
const notes = possibleKeys
|
||||
.map((key) => versionConfig?.[key]?.notes)
|
||||
.find((note) => note);
|
||||
|
||||
if (!notes) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded border border-bunker-500/20 bg-bunker-700/20 p-3">
|
||||
<div className="text-sm text-bunker-300">{notes}</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Database Schema Changes */}
|
||||
{hasDbMigration && (
|
||||
<div className="mt-3 rounded border border-yellow-500/20 bg-yellow-500/10 p-3">
|
||||
<div className="mb-1 text-sm font-medium text-yellow-400">
|
||||
Database Schema Changes Required
|
||||
</div>
|
||||
<div className="mb-2 text-xs text-bunker-300">
|
||||
{typeof dbMigrationDescription === "string"
|
||||
? dbMigrationDescription
|
||||
: "This version includes database schema changes that require migrations."}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-yellow-400">
|
||||
Action:{" "}
|
||||
<span className="font-normal italic">
|
||||
Make sure to backup your database before proceeding
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Breaking Changes */}
|
||||
{hasBreakingChanges && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg
|
||||
className="h-4 w-4 text-red-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-red-400">
|
||||
Breaking Changes ({versionChanges.changes.length})
|
||||
</span>
|
||||
</div>
|
||||
{versionChanges.changes.map((change) => (
|
||||
<div
|
||||
key={`${step.version}-${change.title}`}
|
||||
className="rounded border border-red-500/20 bg-red-500/10 p-3"
|
||||
>
|
||||
<div className="mb-1 text-sm font-medium text-red-400">
|
||||
{change.title}
|
||||
</div>
|
||||
<div className="mb-2 text-xs text-bunker-300">
|
||||
{change.description}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-red-400">
|
||||
Action:{" "}
|
||||
<span className="font-normal italic">{change.action}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full bg-mineshaft-800 p-2">
|
||||
<p className="text-center text-sm text-bunker-400">
|
||||
Made with ❤️ by{" "}
|
||||
<a className="text-primary hover:text-primary/80" href="https://infisical.com">
|
||||
Infisical
|
||||
</a>
|
||||
<br />
|
||||
235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { UpgradePathPage } from "./UpgradePathPage";
|
||||
|
||||
export const Route = createFileRoute("/upgrade-path")({
|
||||
component: UpgradePathPage
|
||||
});
|
||||
+26
-2
@@ -1,7 +1,7 @@
|
||||
import { ClipboardEvent, useRef } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faTriangleExclamation, faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Input,
|
||||
PasswordGenerator
|
||||
PasswordGenerator,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
||||
@@ -217,6 +218,29 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
|
||||
// @ts-expect-error this is for multiple ref single component
|
||||
secretKeyInputRef.current = e;
|
||||
}}
|
||||
warning={
|
||||
secretKey?.includes(" ") ? (
|
||||
<Tooltip
|
||||
className={"w-full max-w-72"}
|
||||
content={
|
||||
<div>
|
||||
Secret key contains whitespaces.
|
||||
<br />
|
||||
<br /> If this is the desired format, you need to provide it as{" "}
|
||||
<code className="rounded-md bg-mineshaft-500 px-1 py-0.5">
|
||||
{encodeURIComponent(secretKey.trim())}
|
||||
</code>{" "}
|
||||
when making API requests.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faWarning}
|
||||
className="absolute right-0 mr-3 text-yellow-600"
|
||||
/>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
placeholder="Type your secret name"
|
||||
onPaste={handlePaste}
|
||||
autoCapitalization={currentProject?.autoCapitalization}
|
||||
|
||||
+24
-2
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faCheck, faClose, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faCheck, faClose, faCopy, faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
@@ -78,6 +78,7 @@ function SecretRenameRow({ environments, getSecretByKey, secretKey, secretPath }
|
||||
control,
|
||||
reset,
|
||||
trigger,
|
||||
watch,
|
||||
getValues,
|
||||
formState: { isDirty, isSubmitting, errors }
|
||||
} = useForm<TFormSchema>({
|
||||
@@ -144,14 +145,35 @@ function SecretRenameRow({ environments, getSecretByKey, secretKey, secretPath }
|
||||
setIsSecNameCopied.on();
|
||||
};
|
||||
|
||||
const currentSecretValue = watch("key");
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(handleFormSubmit)}
|
||||
className="secret-table relative mb-2 flex w-full flex-row items-center justify-between overflow-hidden rounded-lg border border-solid border-mineshaft-700 bg-mineshaft-800 font-inter"
|
||||
>
|
||||
<div className="flex h-11 flex-1 flex-shrink-0 items-center">
|
||||
<span className="flex h-full min-w-[11rem] items-center justify-start border-r-2 border-mineshaft-600 px-4">
|
||||
<span className="flex h-full min-w-[11rem] items-center justify-between gap-2 border-r-2 border-mineshaft-600 px-4">
|
||||
Key
|
||||
{currentSecretValue?.trim()?.includes(" ") &&
|
||||
currentSecretValue?.trim() !== secretKey && (
|
||||
<Tooltip
|
||||
className={"w-full max-w-72"}
|
||||
content={
|
||||
<div>
|
||||
Secret key contains whitespaces.
|
||||
<br />
|
||||
<br /> If this is the desired format, you need to provide it as{" "}
|
||||
<code className="rounded-md bg-mineshaft-500 px-1 py-0.5">
|
||||
{encodeURIComponent(secretKey.trim())}
|
||||
</code>{" "}
|
||||
when making API requests.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faWarning} className="text-yellow-600" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<Controller
|
||||
|
||||
+4
-4
@@ -245,14 +245,14 @@ export const SecretApprovalRequestChanges = ({ approvalRequestId, onGoBack }: Pr
|
||||
: secretApprovalRequestDetails.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="-mt-0.5 flex-grow flex-col">
|
||||
<div className="-mt-0.5 w-[calc(100%-20rem)] flex-grow flex-col">
|
||||
<div className="text-xl">
|
||||
{generateCommitText(
|
||||
secretApprovalRequestDetails.commits,
|
||||
secretApprovalRequestDetails.isReplicated
|
||||
)}
|
||||
</div>
|
||||
<p className="-mt-1 text-xs text-gray-400">
|
||||
<p className="-mt-1 truncate text-xs text-gray-400">
|
||||
By{" "}
|
||||
{secretApprovalRequestDetails?.committerUser ? (
|
||||
<>
|
||||
@@ -559,7 +559,7 @@ export const SecretApprovalRequestChanges = ({ approvalRequestId, onGoBack }: Pr
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div>{requiredApprover?.email}</div>
|
||||
<div className="max-w-[200px] truncate">{requiredApprover?.email}</div>
|
||||
<span className="text-red">*</span>
|
||||
{!isOrgMembershipActive && (
|
||||
<FontAwesomeIcon
|
||||
@@ -630,7 +630,7 @@ export const SecretApprovalRequestChanges = ({ approvalRequestId, onGoBack }: Pr
|
||||
}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span>{reviewer?.email} </span>
|
||||
<span className="max-w-[200px] truncate">{reviewer?.email}</span>
|
||||
{!isOrgMembershipActive && (
|
||||
<FontAwesomeIcon
|
||||
icon={faUserSlash}
|
||||
|
||||
+729
@@ -0,0 +1,729 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Input,
|
||||
SecretInput,
|
||||
Select,
|
||||
SelectItem,
|
||||
Switch,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
OrgGatewayPermissionActions,
|
||||
OrgPermissionSubjects
|
||||
} from "@app/context/OrgPermissionContext/types";
|
||||
import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api";
|
||||
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
|
||||
import { ProjectEnv } from "@app/hooks/api/types";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
import { MetadataForm } from "../../DynamicSecretListView/MetadataForm";
|
||||
|
||||
const passwordRequirementsSchema = z
|
||||
.object({
|
||||
length: z.number().min(1).max(250),
|
||||
required: z
|
||||
.object({
|
||||
lowercase: z.number().min(0),
|
||||
uppercase: z.number().min(0),
|
||||
digits: z.number().min(0),
|
||||
symbols: z.number().min(0)
|
||||
})
|
||||
.refine((data) => {
|
||||
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
|
||||
return total <= 250;
|
||||
}, "Sum of required characters cannot exceed 250"),
|
||||
allowedSymbols: z.string().optional()
|
||||
})
|
||||
.refine((data) => {
|
||||
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
|
||||
return total <= data.length;
|
||||
}, "Sum of required characters cannot exceed the total length");
|
||||
|
||||
const formSchema = z.object({
|
||||
provider: z.object({
|
||||
host: z.string().toLowerCase().min(1),
|
||||
port: z.coerce.number(),
|
||||
database: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
passwordRequirements: passwordRequirementsSchema.optional(),
|
||||
masterCreationStatement: z.string().min(1),
|
||||
creationStatement: z.string().min(1),
|
||||
revocationStatement: z.string().min(1),
|
||||
renewStatement: z.string().optional(),
|
||||
sslEnabled: z.boolean().optional(),
|
||||
ca: z.string().optional(),
|
||||
gatewayId: z.string().optional()
|
||||
}),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
maxTTL: z
|
||||
.string()
|
||||
.optional()
|
||||
.superRefine((val, ctx) => {
|
||||
if (!val) return;
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
name: slugSchema(),
|
||||
environment: z.object({ name: z.string(), slug: z.string() }),
|
||||
metadata: z
|
||||
.object({
|
||||
key: z.string().trim().min(1),
|
||||
value: z.string().trim().default("")
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
usernameTemplate: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
onCompleted: () => void;
|
||||
onCancel: () => void;
|
||||
secretPath: string;
|
||||
projectSlug: string;
|
||||
environments: ProjectEnv[];
|
||||
isSingleEnvironmentMode?: boolean;
|
||||
};
|
||||
|
||||
const getDefaultAzureSqlStatements = () => ({
|
||||
masterCreationStatement: "CREATE LOGIN [{{username}}] WITH PASSWORD = '{{password}}';",
|
||||
creationStatement:
|
||||
"CREATE USER [{{username}}] FOR LOGIN [{{username}}];\nGRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO [{{username}}];",
|
||||
renewStatement: "",
|
||||
revocationStatement: "DROP USER [{{username}}];\nDROP LOGIN [{{username}}];"
|
||||
});
|
||||
|
||||
export const AzureSqlDatabaseInputForm = ({
|
||||
onCompleted,
|
||||
onCancel,
|
||||
environments,
|
||||
secretPath,
|
||||
projectSlug,
|
||||
isSingleEnvironmentMode
|
||||
}: Props) => {
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
watch
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
provider: {
|
||||
port: 1433,
|
||||
...getDefaultAzureSqlStatements(),
|
||||
passwordRequirements: {
|
||||
length: 48,
|
||||
required: {
|
||||
lowercase: 1,
|
||||
uppercase: 1,
|
||||
digits: 1,
|
||||
symbols: 0
|
||||
},
|
||||
allowedSymbols: "-_.~!*"
|
||||
}
|
||||
},
|
||||
environment: isSingleEnvironmentMode ? environments[0] : undefined,
|
||||
usernameTemplate: "{{randomUsername}}"
|
||||
}
|
||||
});
|
||||
|
||||
const createDynamicSecret = useCreateDynamicSecret();
|
||||
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
|
||||
const sslEnabled = watch("provider.sslEnabled");
|
||||
|
||||
const handleCreateDynamicSecret = async ({
|
||||
name,
|
||||
maxTTL,
|
||||
provider,
|
||||
defaultTTL,
|
||||
environment,
|
||||
metadata,
|
||||
usernameTemplate
|
||||
}: TForm) => {
|
||||
if (createDynamicSecret.isPending) return;
|
||||
|
||||
const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}";
|
||||
try {
|
||||
await createDynamicSecret.mutateAsync({
|
||||
provider: {
|
||||
type: DynamicSecretProviders.AzureSqlDatabase,
|
||||
inputs: { ...provider, masterDatabase: "master" }
|
||||
},
|
||||
maxTTL,
|
||||
name,
|
||||
path: secretPath,
|
||||
defaultTTL,
|
||||
projectSlug,
|
||||
environmentSlug: environment.slug,
|
||||
metadata,
|
||||
usernameTemplate:
|
||||
!usernameTemplate || isDefaultUsernameTemplate ? undefined : usernameTemplate
|
||||
});
|
||||
onCompleted();
|
||||
} catch {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create dynamic secret"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Secret Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="dynamic-secret" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-32">
|
||||
<Controller
|
||||
control={control}
|
||||
name="defaultTTL"
|
||||
defaultValue="1h"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Default TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Controller
|
||||
control={control}
|
||||
name="maxTTL"
|
||||
defaultValue="24h"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Max TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MetadataForm control={control} />
|
||||
<div>
|
||||
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
||||
Configuration
|
||||
</div>
|
||||
<div>
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.gatewayId"
|
||||
defaultValue=""
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewaysLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.host"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Host"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="server.database.windows.net" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.port"
|
||||
defaultValue={1433}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Port"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="number" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.username"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="User"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} autoComplete="off" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Password"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="password" autoComplete="new-password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.database"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Database"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="mydatabase" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 mt-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.sslEnabled"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
|
||||
<Switch
|
||||
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
|
||||
id="azure-sql-ds-ssl-enabled"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={value}
|
||||
onCheckedChange={onChange}
|
||||
>
|
||||
Encrypt Connection (SSL)
|
||||
</Switch>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{sslEnabled && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.ca"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isOptional
|
||||
label="CA (SSL)"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<SecretInput
|
||||
{...field}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Accordion type="multiple" className="mb-2 w-full bg-mineshaft-700">
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger>
|
||||
Creation, Revocation & Renew Statements (optional)
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="usernameTemplate"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Username Template"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value || undefined}
|
||||
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||
placeholder="{{randomUsername}}"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mb-4 text-sm text-mineshaft-300">
|
||||
Customize SQL statements for managing Azure SQL Database user lifecycle
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.masterCreationStatement"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Master Creation Statement"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText="Statement to create login in master database"
|
||||
>
|
||||
<TextArea
|
||||
{...field}
|
||||
reSize="none"
|
||||
rows={3}
|
||||
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.creationStatement"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Creation Statement"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText="Statement to create user in target database and grant permissions"
|
||||
>
|
||||
<TextArea
|
||||
{...field}
|
||||
reSize="none"
|
||||
rows={3}
|
||||
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.revocationStatement"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Revocation Statement"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText="Statement to drop user and login"
|
||||
>
|
||||
<TextArea
|
||||
{...field}
|
||||
reSize="none"
|
||||
rows={3}
|
||||
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.renewStatement"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Renew Statement"
|
||||
helperText="username and expiration are dynamically provisioned"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<TextArea
|
||||
{...field}
|
||||
reSize="none"
|
||||
rows={3}
|
||||
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<Accordion type="multiple" className="mb-2 mt-4 w-full bg-mineshaft-700">
|
||||
<AccordionItem value="password-config">
|
||||
<AccordionTrigger>Password Configuration (optional)</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="mb-4 text-sm text-mineshaft-300">
|
||||
Set constraints on the generated database password
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.passwordRequirements.length"
|
||||
defaultValue={48}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Password Length"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={250}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Minimum Required Character Counts</h4>
|
||||
<div className="text-sm text-gray-500">
|
||||
{(() => {
|
||||
const total = Object.values(
|
||||
watch("provider.passwordRequirements.required") || {}
|
||||
).reduce((sum, count) => sum + Number(count || 0), 0);
|
||||
const length = watch("provider.passwordRequirements.length") || 0;
|
||||
const isError = total > length;
|
||||
return (
|
||||
<span className={isError ? "text-red-500" : ""}>
|
||||
Total required characters: {total}{" "}
|
||||
{isError ? `(exceeds length of ${length})` : ""}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.passwordRequirements.required.lowercase"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Lowercase Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of lowercase letters"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.passwordRequirements.required.uppercase"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Uppercase Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of uppercase letters"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.passwordRequirements.required.digits"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Digit Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of digits"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.passwordRequirements.required.symbols"
|
||||
defaultValue={0}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Symbol Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of symbols"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Allowed Symbols</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.passwordRequirements.allowedSymbols"
|
||||
defaultValue="-_.~!*"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Symbols to use in password"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Default: -_.~!*"
|
||||
>
|
||||
<Input {...field} placeholder="-_.~!*" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
{!isSingleEnvironmentMode && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<FilterableSelect
|
||||
options={environments}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Select the environment to create secret in..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.slug}
|
||||
menuPlacement="top"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center space-x-4">
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Submit
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+25
@@ -29,6 +29,7 @@ import { ProjectEnv } from "@app/hooks/api/types";
|
||||
import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm";
|
||||
import { AwsIamInputForm } from "./AwsIamInputForm";
|
||||
import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
|
||||
import { AzureSqlDatabaseInputForm } from "./AzureSqlDatabaseInputForm";
|
||||
import { CassandraInputForm } from "./CassandraInputForm";
|
||||
import { CouchbaseInputForm } from "./CouchbaseInputForm";
|
||||
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
|
||||
@@ -112,6 +113,11 @@ const DYNAMIC_SECRET_LIST = [
|
||||
provider: DynamicSecretProviders.AzureEntraId,
|
||||
title: "Azure Entra ID"
|
||||
},
|
||||
{
|
||||
icon: <VscAzure size="1.5rem" />,
|
||||
provider: DynamicSecretProviders.AzureSqlDatabase,
|
||||
title: "Azure SQL Database"
|
||||
},
|
||||
{
|
||||
icon: <SiFiles size="1.5rem" />,
|
||||
provider: DynamicSecretProviders.Ldap,
|
||||
@@ -443,6 +449,25 @@ export const CreateDynamicSecretForm = ({
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{wizardStep === WizardSteps.ProviderInputs &&
|
||||
selectedProvider === DynamicSecretProviders.AzureSqlDatabase && (
|
||||
<motion.div
|
||||
key="dynamic-azure-sql-database-step"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
>
|
||||
<AzureSqlDatabaseInputForm
|
||||
onCompleted={handleFormReset}
|
||||
onCancel={handleFormReset}
|
||||
projectSlug={projectSlug}
|
||||
secretPath={secretPath}
|
||||
environments={environments}
|
||||
isSingleEnvironmentMode={isSingleEnvironmentMode}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{wizardStep === WizardSteps.ProviderInputs &&
|
||||
selectedProvider === DynamicSecretProviders.Ldap && (
|
||||
<motion.div
|
||||
|
||||
+25
-2
@@ -1,12 +1,12 @@
|
||||
import { ClipboardEvent, useRef } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faTriangleExclamation, faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input, PasswordGenerator } from "@app/components/v2";
|
||||
import { Button, FormControl, Input, PasswordGenerator, Tooltip } from "@app/components/v2";
|
||||
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||
@@ -178,6 +178,29 @@ export const CreateSecretForm = ({
|
||||
// @ts-expect-error this is for multiple ref single component
|
||||
secretKeyInputRef.current = e;
|
||||
}}
|
||||
warning={
|
||||
secretKey?.includes(" ") ? (
|
||||
<Tooltip
|
||||
className={"w-full max-w-72"}
|
||||
content={
|
||||
<div>
|
||||
Secret key contains whitespaces.
|
||||
<br />
|
||||
<br /> If this is the desired format, you need to provide it as{" "}
|
||||
<code className="rounded-md bg-mineshaft-500 px-1 py-0.5">
|
||||
{encodeURIComponent(secretKey.trim())}
|
||||
</code>{" "}
|
||||
when making API requests.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faWarning}
|
||||
className="absolute right-0 mr-3 text-yellow-600"
|
||||
/>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
placeholder="Type your secret name"
|
||||
onPaste={handlePaste}
|
||||
autoCapitalization={autoCapitalize}
|
||||
|
||||
+2
-1
@@ -138,7 +138,8 @@ const renderOutputForm = (
|
||||
provider === DynamicSecretProviders.MongoAtlas ||
|
||||
provider === DynamicSecretProviders.MongoDB ||
|
||||
provider === DynamicSecretProviders.Vertica ||
|
||||
provider === DynamicSecretProviders.SapAse
|
||||
provider === DynamicSecretProviders.SapAse ||
|
||||
provider === DynamicSecretProviders.AzureSqlDatabase
|
||||
) {
|
||||
const { DB_PASSWORD, DB_USERNAME } = data as { DB_USERNAME: string; DB_PASSWORD: string };
|
||||
return (
|
||||
|
||||
+686
@@ -0,0 +1,686 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
SecretInput,
|
||||
Select,
|
||||
SelectItem,
|
||||
Switch,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionSubjects } from "@app/context";
|
||||
import { OrgGatewayPermissionActions } from "@app/context/OrgPermissionContext/types";
|
||||
import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api";
|
||||
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
import { MetadataForm } from "../MetadataForm";
|
||||
|
||||
const passwordRequirementsSchema = z
|
||||
.object({
|
||||
length: z.number().min(1).max(250),
|
||||
required: z
|
||||
.object({
|
||||
lowercase: z.number().min(0),
|
||||
uppercase: z.number().min(0),
|
||||
digits: z.number().min(0),
|
||||
symbols: z.number().min(0)
|
||||
})
|
||||
.refine((data) => {
|
||||
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
|
||||
return total <= 250;
|
||||
}, "Sum of required characters cannot exceed 250"),
|
||||
allowedSymbols: z.string().optional()
|
||||
})
|
||||
.refine((data) => {
|
||||
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
|
||||
return total <= data.length;
|
||||
}, "Sum of required characters cannot exceed the total length");
|
||||
|
||||
const formSchema = z.object({
|
||||
inputs: z
|
||||
.object({
|
||||
host: z.string().toLowerCase().min(1),
|
||||
port: z.number(),
|
||||
database: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
passwordRequirements: passwordRequirementsSchema.optional(),
|
||||
masterCreationStatement: z.string().min(1),
|
||||
creationStatement: z.string().min(1),
|
||||
revocationStatement: z.string().min(1),
|
||||
renewStatement: z.string().optional(),
|
||||
ca: z.string().optional(),
|
||||
sslEnabled: z.boolean().optional(),
|
||||
gatewayId: z.string().optional()
|
||||
})
|
||||
.partial(),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
maxTTL: z
|
||||
.string()
|
||||
.optional()
|
||||
.superRefine((val, ctx) => {
|
||||
if (!val) return;
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
newName: slugSchema().optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
key: z.string().trim().min(1),
|
||||
value: z.string().trim().default("")
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
usernameTemplate: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
dynamicSecret: TDynamicSecret & { inputs: unknown };
|
||||
secretPath: string;
|
||||
projectSlug: string;
|
||||
environment: string;
|
||||
};
|
||||
|
||||
export const EditDynamicSecretAzureSqlDatabaseForm = ({
|
||||
onClose,
|
||||
dynamicSecret,
|
||||
environment,
|
||||
secretPath,
|
||||
projectSlug
|
||||
}: Props) => {
|
||||
const getDefaultPasswordRequirements = () => ({
|
||||
length: 48,
|
||||
required: {
|
||||
lowercase: 1,
|
||||
uppercase: 1,
|
||||
digits: 1,
|
||||
symbols: 0
|
||||
},
|
||||
allowedSymbols: "-_.~!*"
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
watch
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: {
|
||||
defaultTTL: dynamicSecret.defaultTTL,
|
||||
maxTTL: dynamicSecret.maxTTL || "",
|
||||
newName: dynamicSecret.name,
|
||||
metadata: dynamicSecret.metadata?.map((item) => ({ key: item.key, value: item.value })) || [],
|
||||
usernameTemplate: dynamicSecret?.usernameTemplate || "{{randomUsername}}",
|
||||
inputs: {
|
||||
...(dynamicSecret.inputs as TForm["inputs"]),
|
||||
passwordRequirements:
|
||||
(dynamicSecret.inputs as TForm["inputs"])?.passwordRequirements ||
|
||||
getDefaultPasswordRequirements()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const updateDynamicSecret = useUpdateDynamicSecret();
|
||||
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
|
||||
const sslEnabled = watch("inputs.sslEnabled");
|
||||
|
||||
const handleUpdateDynamicSecret = async ({
|
||||
inputs,
|
||||
maxTTL,
|
||||
defaultTTL,
|
||||
newName,
|
||||
metadata,
|
||||
usernameTemplate
|
||||
}: TForm) => {
|
||||
if (updateDynamicSecret.isPending) return;
|
||||
|
||||
try {
|
||||
const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}";
|
||||
await updateDynamicSecret.mutateAsync({
|
||||
projectSlug,
|
||||
environmentSlug: environment,
|
||||
path: secretPath,
|
||||
name: dynamicSecret.name,
|
||||
data: {
|
||||
maxTTL: maxTTL || undefined,
|
||||
defaultTTL,
|
||||
inputs: inputs ? { ...inputs, masterDatabase: "master" } : undefined,
|
||||
newName: newName === dynamicSecret.name ? undefined : newName,
|
||||
metadata,
|
||||
usernameTemplate: !usernameTemplate || isDefaultUsernameTemplate ? null : usernameTemplate
|
||||
}
|
||||
});
|
||||
onClose();
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully updated dynamic secret"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update dynamic secret"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
name="newName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Secret Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="dynamic-secret" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-32">
|
||||
<Controller
|
||||
control={control}
|
||||
name="defaultTTL"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Default TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Controller
|
||||
control={control}
|
||||
name="maxTTL"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Max TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MetadataForm control={control} />
|
||||
<div>
|
||||
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
||||
Configuration
|
||||
</div>
|
||||
<div>
|
||||
<OrgPermissionCan
|
||||
I={OrgGatewayPermissionActions.AttachGateways}
|
||||
a={OrgPermissionSubjects.Gateway}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.gatewayId"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
label="Gateway"
|
||||
>
|
||||
<Tooltip
|
||||
isDisabled={isAllowed}
|
||||
content="Restricted access. You don't have permission to attach gateways to resources."
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={!isAllowed}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewaysLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem
|
||||
value={null as unknown as string}
|
||||
onClick={() => onChange(undefined)}
|
||||
>
|
||||
Internet Gateway
|
||||
</SelectItem>
|
||||
{gateways?.map((el) => (
|
||||
<SelectItem value={el.id} key={el.id}>
|
||||
{el.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.host"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Host"
|
||||
className="flex-grow"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="server.database.windows.net" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.port"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Port"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="number" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.username"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="User"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} autoComplete="off" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Password"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="password" autoComplete="new-password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.database"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Database"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="mydatabase" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 mt-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.sslEnabled"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
|
||||
<Switch
|
||||
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
|
||||
id="azure-sql-edit-ssl-enabled"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={value}
|
||||
onCheckedChange={onChange}
|
||||
>
|
||||
Encrypt Connection (SSL)
|
||||
</Switch>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{sslEnabled && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.ca"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isOptional
|
||||
label="CA (SSL)"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<SecretInput
|
||||
{...field}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Accordion type="multiple" className="mb-2 w-full bg-mineshaft-700">
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger>
|
||||
Creation, Revocation & Renew Statements (optional)
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="usernameTemplate"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Username Template"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value || undefined}
|
||||
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||
placeholder="{{randomUsername}}"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mb-4 text-sm text-mineshaft-300">
|
||||
Customize SQL statements for managing Azure SQL Database user lifecycle
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.masterCreationStatement"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Master Creation Statement"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText="Statement to create login in master database"
|
||||
>
|
||||
<TextArea
|
||||
{...field}
|
||||
reSize="none"
|
||||
rows={3}
|
||||
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.creationStatement"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Creation Statement"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText="Statement to create user in target database and grant permissions"
|
||||
>
|
||||
<TextArea
|
||||
{...field}
|
||||
reSize="none"
|
||||
rows={3}
|
||||
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.revocationStatement"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Revocation Statement"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText="Statement to drop user and login"
|
||||
>
|
||||
<TextArea
|
||||
{...field}
|
||||
reSize="none"
|
||||
rows={3}
|
||||
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.renewStatement"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Renew Statement"
|
||||
helperText="username and expiration are dynamically provisioned"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<TextArea
|
||||
{...field}
|
||||
reSize="none"
|
||||
rows={3}
|
||||
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<Accordion type="multiple" className="mb-2 mt-4 w-full bg-mineshaft-700">
|
||||
<AccordionItem value="password-config">
|
||||
<AccordionTrigger>Password Configuration (optional)</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="mb-4 text-sm text-mineshaft-300">
|
||||
Set constraints on the generated database password
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.passwordRequirements.length"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Password Length"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={250}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Minimum Required Character Counts</h4>
|
||||
<div className="text-sm text-gray-500">
|
||||
{(() => {
|
||||
const total = Object.values(
|
||||
watch("inputs.passwordRequirements.required") || {}
|
||||
).reduce((sum, count) => sum + Number(count || 0), 0);
|
||||
const length = watch("inputs.passwordRequirements.length") || 0;
|
||||
const isError = total > length;
|
||||
return (
|
||||
<span className={isError ? "text-red-500" : ""}>
|
||||
Total required characters: {total}{" "}
|
||||
{isError ? `(exceeds length of ${length})` : ""}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.passwordRequirements.required.lowercase"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Lowercase Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of lowercase letters"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.passwordRequirements.required.uppercase"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Uppercase Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of uppercase letters"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.passwordRequirements.required.digits"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Digit Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of digits"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.passwordRequirements.required.symbols"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Symbol Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of symbols"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Allowed Symbols</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.passwordRequirements.allowedSymbols"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Symbols to use in password"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Default: -_.~!*"
|
||||
>
|
||||
<Input {...field} placeholder="-_.~!*" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center space-x-4">
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+19
@@ -7,6 +7,7 @@ import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
|
||||
import { EditDynamicSecretAwsElastiCacheProviderForm } from "./EditDynamicSecretAwsElastiCacheProviderForm";
|
||||
import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm";
|
||||
import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntraIdForm";
|
||||
import { EditDynamicSecretAzureSqlDatabaseForm } from "./EditDynamicSecretAzureSqlDatabaseForm";
|
||||
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
|
||||
import { EditDynamicSecretCouchbaseForm } from "./EditDynamicSecretCouchbaseForm";
|
||||
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
|
||||
@@ -232,6 +233,24 @@ export const EditDynamicSecretForm = ({
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{dynamicSecretDetails?.type === DynamicSecretProviders.AzureSqlDatabase && (
|
||||
<motion.div
|
||||
key="azure-sql-database-edit"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
>
|
||||
<EditDynamicSecretAzureSqlDatabaseForm
|
||||
onClose={onClose}
|
||||
projectSlug={projectSlug}
|
||||
secretPath={secretPath}
|
||||
dynamicSecret={dynamicSecretDetails}
|
||||
environment={environment}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{dynamicSecretDetails?.type === DynamicSecretProviders.Ldap && (
|
||||
<motion.div
|
||||
key="ldap-edit"
|
||||
|
||||
+4
-2
@@ -47,7 +47,8 @@ import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProject,
|
||||
useProjectPermission
|
||||
useProjectPermission,
|
||||
useSubscription
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { getProjectBaseURL } from "@app/helpers/project";
|
||||
@@ -252,11 +253,12 @@ export const SecretDetailSidebar = ({
|
||||
secretId: secret?.id
|
||||
});
|
||||
|
||||
const { subscription } = useSubscription();
|
||||
const { data: secretAccessList, isPending } = useGetSecretAccessList({
|
||||
projectId: currentProject.id,
|
||||
environment,
|
||||
secretPath,
|
||||
secretKey
|
||||
secretKey: subscription?.secretAccessInsights ? secretKey : ""
|
||||
});
|
||||
|
||||
const handleTagSelect = (tag: WsTag) => {
|
||||
|
||||
+25
-1
@@ -43,7 +43,7 @@ import { twMerge } from "tailwind-merge";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faEyeSlash, faKey, faRotate } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faEyeSlash, faKey, faRotate, faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||
import { format } from "date-fns";
|
||||
import { CreateReminderForm } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm";
|
||||
@@ -487,6 +487,30 @@ export const SecretItem = memo(
|
||||
placeholder={error?.message}
|
||||
isError={Boolean(error)}
|
||||
onKeyUp={() => trigger("key")}
|
||||
warning={
|
||||
field?.value !== (originalSecret.originalKey || originalSecret.key) &&
|
||||
field.value?.includes(" ") ? (
|
||||
<Tooltip
|
||||
className={"w-full max-w-72"}
|
||||
content={
|
||||
<div>
|
||||
Secret key contains whitespaces.
|
||||
<br />
|
||||
<br /> If this is the desired format, you need to provide it as{" "}
|
||||
<code className="rounded-md bg-mineshaft-500 px-1 py-0.5">
|
||||
{encodeURIComponent(field.value.trim())}
|
||||
</code>{" "}
|
||||
when making API requests.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faWarning}
|
||||
className="text-yellow-600 opacity-60"
|
||||
/>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
{...field}
|
||||
className="w-full px-0 placeholder:text-red-500 focus:text-bunker-100 focus:ring-transparent"
|
||||
/>
|
||||
|
||||
+398
-112
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
import TotpRegistration from "@app/components/mfa/TotpRegistration";
|
||||
import { RecoveryCodesDownload } from "@app/components/mfa/RecoveryCodesDownload";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
@@ -8,9 +10,9 @@ import {
|
||||
DeleteActionModal,
|
||||
EmailServiceSetupModal,
|
||||
FormControl,
|
||||
Input,
|
||||
Select,
|
||||
SelectItem,
|
||||
Switch
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { useGetUser, userKeys, useUpdateUserMfa } from "@app/hooks/api";
|
||||
@@ -18,9 +20,13 @@ import { MfaMethod } from "@app/hooks/api/auth/types";
|
||||
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
||||
import {
|
||||
useCreateNewTotpRecoveryCodes,
|
||||
useDeleteUserTotpConfiguration
|
||||
useDeleteUserTotpConfiguration,
|
||||
useVerifyUserTotpRegistration
|
||||
} from "@app/hooks/api/users/mutation";
|
||||
import { useGetUserTotpConfiguration } from "@app/hooks/api/users/queries";
|
||||
import {
|
||||
useGetUserTotpConfiguration,
|
||||
useGetUserTotpRegistration
|
||||
} from "@app/hooks/api/users/queries";
|
||||
import { AuthMethod } from "@app/hooks/api/users/types";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
@@ -28,24 +34,65 @@ export const MFASection = () => {
|
||||
const { data: user } = useGetUser();
|
||||
const { mutateAsync } = useUpdateUserMfa();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
isMfaEnabled: user?.isMfaEnabled || false,
|
||||
selectedMfaMethod: user?.selectedMfaMethod || MfaMethod.EMAIL
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState("");
|
||||
const [showMobileAuthSetup, setShowMobileAuthSetup] = useState(false);
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"setUpEmail",
|
||||
"deleteTotpConfig"
|
||||
"deleteTotpConfig",
|
||||
"downloadRecoveryCodes"
|
||||
] as const);
|
||||
const [shouldShowRecoveryCodes, setShouldShowRecoveryCodes] = useToggle();
|
||||
const { data: totpConfiguration, isPending: isTotpConfigurationLoading } =
|
||||
useGetUserTotpConfiguration();
|
||||
const { data: totpConfiguration } = useGetUserTotpConfiguration();
|
||||
const { data: totpRegistration, isPending: isTotpRegistrationLoading } =
|
||||
useGetUserTotpRegistration({
|
||||
enabled: showMobileAuthSetup
|
||||
});
|
||||
const { mutateAsync: deleteTotpConfiguration } = useDeleteUserTotpConfiguration();
|
||||
const { mutateAsync: createTotpRecoveryCodes } = useCreateNewTotpRecoveryCodes();
|
||||
const { mutateAsync: verifyUserTotp } = useVerifyUserTotpRegistration();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: serverDetails } = useFetchServerStatus();
|
||||
|
||||
// Update form data when user data changes
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setFormData({
|
||||
isMfaEnabled: user.isMfaEnabled,
|
||||
selectedMfaMethod: user.selectedMfaMethod || MfaMethod.EMAIL
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
const generateQRCode = async () => {
|
||||
if (totpRegistration?.otpUrl) {
|
||||
const url = await QRCode.toDataURL(totpRegistration.otpUrl);
|
||||
setQrCodeUrl(url);
|
||||
}
|
||||
};
|
||||
|
||||
if (showMobileAuthSetup && totpRegistration?.otpUrl) {
|
||||
generateQRCode();
|
||||
}
|
||||
}, [totpRegistration, showMobileAuthSetup]);
|
||||
|
||||
const handleTotpDeletion = async () => {
|
||||
try {
|
||||
await deleteTotpConfiguration();
|
||||
|
||||
await mutateAsync({
|
||||
selectedMfaMethod: MfaMethod.EMAIL
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully deleted mobile authenticator",
|
||||
text: "Successfully deleted mobile authenticator and switched to email authentication",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
@@ -82,30 +129,35 @@ export const MFASection = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateSelectedMfa = async (mfaMethod: MfaMethod) => {
|
||||
try {
|
||||
if (!user) return;
|
||||
const handleFormDataChange = async (field: string, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
|
||||
await mutateAsync({
|
||||
selectedMfaMethod: mfaMethod
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully updated selected 2FA method",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
text: "Something went wrong while updating selected 2FA method.",
|
||||
type: "error"
|
||||
});
|
||||
console.error(err);
|
||||
// Show mobile auth setup when mobile authenticator is selected and we're enabling 2FA
|
||||
if (field === "selectedMfaMethod" && value === MfaMethod.TOTP && formData.isMfaEnabled) {
|
||||
setShowMobileAuthSetup(true);
|
||||
} else if (field === "selectedMfaMethod" && value !== MfaMethod.TOTP) {
|
||||
setShowMobileAuthSetup(false);
|
||||
setTotpCode("");
|
||||
setShouldShowRecoveryCodes.off();
|
||||
if (totpConfiguration?.isVerified) {
|
||||
await deleteTotpConfiguration().catch(console.error);
|
||||
}
|
||||
} else if (field === "isMfaEnabled" && value && formData.selectedMfaMethod === MfaMethod.TOTP) {
|
||||
setShowMobileAuthSetup(true);
|
||||
} else if (field === "isMfaEnabled" && !value) {
|
||||
setShowMobileAuthSetup(false);
|
||||
setTotpCode("");
|
||||
setShouldShowRecoveryCodes.off();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMfa = async (state: boolean) => {
|
||||
const handleSaveChanges = async () => {
|
||||
try {
|
||||
if (!user) return;
|
||||
|
||||
if (user.authMethods.includes(AuthMethod.LDAP)) {
|
||||
createNotification({
|
||||
text: "Two-factor authentication is not available for LDAP users.",
|
||||
@@ -114,119 +166,353 @@ export const MFASection = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const newUser = await mutateAsync({
|
||||
isMfaEnabled: state
|
||||
});
|
||||
if (!serverDetails?.emailConfigured && formData.isMfaEnabled) {
|
||||
handlePopUpOpen("setUpEmail");
|
||||
return;
|
||||
}
|
||||
|
||||
createNotification({
|
||||
text: `${
|
||||
newUser.isMfaEnabled
|
||||
? "Successfully turned on two-factor authentication."
|
||||
: "Successfully turned off two-factor authentication."
|
||||
}`,
|
||||
type: "success"
|
||||
});
|
||||
setIsLoading(true);
|
||||
|
||||
// If enabling 2FA with mobile authenticator, verify TOTP first
|
||||
if (
|
||||
formData.isMfaEnabled &&
|
||||
formData.selectedMfaMethod === MfaMethod.TOTP &&
|
||||
!totpConfiguration?.isVerified
|
||||
) {
|
||||
if (!totpCode.trim()) {
|
||||
createNotification({
|
||||
text: "Please enter the verification code from your authenticator app",
|
||||
type: "error"
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await verifyUserTotp({ totp: totpCode });
|
||||
|
||||
handlePopUpOpen("downloadRecoveryCodes");
|
||||
|
||||
createNotification({
|
||||
text: "Successfully configured mobile authenticator. Please save your recovery codes!",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: userKeys.totpConfiguration });
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to verify TOTP code. Please try again.",
|
||||
type: "error"
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If disabling 2FA and there's a TOTP configuration, delete it
|
||||
if (!formData.isMfaEnabled && user.isMfaEnabled && totpConfiguration?.isVerified) {
|
||||
try {
|
||||
await deleteTotpConfiguration();
|
||||
createNotification({
|
||||
text: "Mobile authenticator removed",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
// Refresh TOTP configuration
|
||||
await queryClient.invalidateQueries({ queryKey: userKeys.totpConfiguration });
|
||||
} catch {
|
||||
// Continue with disabling 2FA even if TOTP deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
const updates: any = {};
|
||||
|
||||
// Only update if values have changed
|
||||
if (formData.isMfaEnabled !== user.isMfaEnabled) {
|
||||
updates.isMfaEnabled = formData.isMfaEnabled;
|
||||
}
|
||||
|
||||
if (formData.selectedMfaMethod !== user.selectedMfaMethod) {
|
||||
updates.selectedMfaMethod = formData.selectedMfaMethod;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await mutateAsync(updates);
|
||||
|
||||
createNotification({
|
||||
text: "Successfully updated two-factor authentication settings",
|
||||
type: "success"
|
||||
});
|
||||
}
|
||||
|
||||
// Reset form state
|
||||
setShowMobileAuthSetup(false);
|
||||
setTotpCode("");
|
||||
setShouldShowRecoveryCodes.off();
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
text: "Something went wrong while toggling the two-factor authentication.",
|
||||
text: "Something went wrong while updating two-factor authentication settings.",
|
||||
type: "error"
|
||||
});
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasChanges =
|
||||
user &&
|
||||
(formData.isMfaEnabled !== user.isMfaEnabled ||
|
||||
formData.selectedMfaMethod !== user.selectedMfaMethod);
|
||||
|
||||
const isFormValid = () => {
|
||||
if (!formData.isMfaEnabled) return true;
|
||||
if (formData.selectedMfaMethod === MfaMethod.EMAIL) return true;
|
||||
if (formData.selectedMfaMethod === MfaMethod.TOTP) {
|
||||
if (totpConfiguration?.isVerified) return true;
|
||||
return totpCode.trim().length > 0;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 max-w-6xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<p className="mb-4 text-xl font-semibold text-mineshaft-100">Two-factor Authentication</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSaveChanges();
|
||||
}}
|
||||
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
>
|
||||
<h2 className="mb-6 text-xl font-semibold text-mineshaft-100">Two-factor Authentication</h2>
|
||||
|
||||
{user && (
|
||||
<Switch
|
||||
className="data-[state=checked]:bg-primary"
|
||||
id="isTwoFAEnabled"
|
||||
isChecked={user?.isMfaEnabled}
|
||||
onCheckedChange={(state) => {
|
||||
if (serverDetails?.emailConfigured) {
|
||||
toggleMfa(state as boolean);
|
||||
} else {
|
||||
handlePopUpOpen("setUpEmail");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Enable 2-factor authentication
|
||||
</Switch>
|
||||
)}
|
||||
{user?.isMfaEnabled && (
|
||||
<FormControl label="Selected 2FA method" className="mt-3">
|
||||
<Select
|
||||
className="min-w-[20rem] border border-mineshaft-500"
|
||||
onValueChange={updateSelectedMfa}
|
||||
defaultValue={user.selectedMfaMethod ?? MfaMethod.EMAIL}
|
||||
>
|
||||
<SelectItem value={MfaMethod.EMAIL} key="mfa-method-email">
|
||||
Email
|
||||
</SelectItem>
|
||||
<SelectItem value={MfaMethod.TOTP} key="mfa-method-totp">
|
||||
Mobile Authenticator
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
<div className="mt-8 text-lg font-semibold text-mineshaft-100">Mobile Authenticator</div>
|
||||
{isTotpConfigurationLoading ? (
|
||||
<ContentLoader />
|
||||
) : (
|
||||
<div>
|
||||
{totpConfiguration?.isVerified ? (
|
||||
<div className="mt-2">
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button colorSchema="secondary" onClick={setShouldShowRecoveryCodes.toggle}>
|
||||
{shouldShowRecoveryCodes ? "Hide recovery codes" : "Show recovery codes"}
|
||||
</Button>
|
||||
<Button colorSchema="secondary" onClick={handleGenerateMoreRecoveryCodes}>
|
||||
Generate more codes
|
||||
</Button>
|
||||
<Button colorSchema="danger" onClick={() => handlePopUpOpen("deleteTotpConfig")}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
{shouldShowRecoveryCodes && totpConfiguration.recoveryCodes && (
|
||||
<div className="mt-4 bg-mineshaft-600 p-4">
|
||||
{totpConfiguration.recoveryCodes.map((code) => (
|
||||
<div key={code}>{code}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div className="max-w-sm">
|
||||
<FormControl
|
||||
label="Enable two-factor authentication"
|
||||
helperText="Protect your account with an additional verification step"
|
||||
>
|
||||
<Select
|
||||
value={formData.isMfaEnabled ? "enabled" : "disabled"}
|
||||
onValueChange={(value) =>
|
||||
handleFormDataChange("isMfaEnabled", value === "enabled")
|
||||
}
|
||||
className="w-full"
|
||||
position="popper"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
<SelectItem value="enabled">Enabled</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
{formData.isMfaEnabled && (
|
||||
<div className="max-w-sm">
|
||||
<FormControl
|
||||
label="Authentication method"
|
||||
helperText="Choose your preferred method for two-factor authentication"
|
||||
>
|
||||
<Select
|
||||
value={formData.selectedMfaMethod}
|
||||
onValueChange={(value) =>
|
||||
handleFormDataChange("selectedMfaMethod", value as MfaMethod)
|
||||
}
|
||||
className="w-full"
|
||||
position="popper"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
>
|
||||
<SelectItem value={MfaMethod.EMAIL}>Email</SelectItem>
|
||||
<SelectItem value={MfaMethod.TOTP}>Mobile Authenticator</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-gray-400">
|
||||
For added security, you can configure a mobile authenticator and set it as your
|
||||
selected 2FA method.
|
||||
)}
|
||||
|
||||
{showMobileAuthSetup && !totpConfiguration?.isVerified && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="mb-6 text-lg font-semibold text-mineshaft-100">
|
||||
Setup Mobile Authenticator
|
||||
</h3>
|
||||
|
||||
<div className="mb-8">
|
||||
<h4 className="mb-2 text-sm font-medium text-mineshaft-200">
|
||||
Step 1: Scan QR Code
|
||||
</h4>
|
||||
<p className="mb-4 text-sm text-mineshaft-300">
|
||||
Download a two-factor authentication app (Google Authenticator, Authy, etc.) and
|
||||
scan the QR code below
|
||||
</p>
|
||||
|
||||
<div>
|
||||
{isTotpRegistrationLoading && (
|
||||
<div className="py-12">
|
||||
<ContentLoader />
|
||||
</div>
|
||||
)}
|
||||
{!isTotpRegistrationLoading && qrCodeUrl && (
|
||||
<div>
|
||||
<div className="mb-4 w-72 pl-4">
|
||||
<div className="inline-flex items-center justify-center rounded-xl bg-white p-6 shadow-lg">
|
||||
<img
|
||||
src={qrCodeUrl}
|
||||
alt="QR Code for mobile authenticator setup"
|
||||
className="h-48 w-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{totpRegistration?.otpUrl && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-mineshaft-400">
|
||||
Can't scan? Enter this code manually:{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-3 py-1 font-mono text-sm text-mineshaft-100">
|
||||
{totpRegistration.otpUrl.split("secret=")[1]?.split("&")[0] ||
|
||||
"Loading..."}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-6 mt-6 flex min-w-full">
|
||||
<TotpRegistration
|
||||
onComplete={async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: userKeys.totpConfiguration });
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-mineshaft-200">
|
||||
Step 2: Enter verification code
|
||||
</h4>
|
||||
<p className="mb-4 text-sm text-mineshaft-300">
|
||||
Enter the 6-digit code from your authenticator app to complete setup
|
||||
</p>
|
||||
|
||||
<div className="max-w-48">
|
||||
<FormControl isRequired>
|
||||
<Input
|
||||
value={totpCode}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/\D/g, "").slice(0, 6);
|
||||
setTotpCode(value);
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
const pastedData = e.clipboardData
|
||||
.getData("text")
|
||||
.replace(/\D/g, "")
|
||||
.slice(0, 6);
|
||||
setTotpCode(pastedData);
|
||||
}}
|
||||
placeholder="Enter 2FA code"
|
||||
maxLength={6}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasChanges && (
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isLoading}
|
||||
disabled={!isFormValid()}
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
if (user) {
|
||||
setFormData({
|
||||
isMfaEnabled: user.isMfaEnabled,
|
||||
selectedMfaMethod: user.selectedMfaMethod || MfaMethod.EMAIL
|
||||
});
|
||||
}
|
||||
setShowMobileAuthSetup(false);
|
||||
setTotpCode("");
|
||||
setShouldShowRecoveryCodes.off();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className="border-mineshaft-500 text-mineshaft-300 hover:border-mineshaft-400"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user?.isMfaEnabled && totpConfiguration?.isVerified && (
|
||||
<div className="mt-8 border-t border-mineshaft-600 pt-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-mineshaft-100">
|
||||
Mobile Authenticator Management
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="outline_bg"
|
||||
onClick={setShouldShowRecoveryCodes.toggle}
|
||||
>
|
||||
{shouldShowRecoveryCodes ? "Hide recovery codes" : "Show recovery codes"}
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="outline_bg"
|
||||
onClick={handleGenerateMoreRecoveryCodes}
|
||||
>
|
||||
Generate more codes
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="danger"
|
||||
variant="outline_bg"
|
||||
onClick={() => handlePopUpOpen("deleteTotpConfig")}
|
||||
>
|
||||
Remove Authenticator
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{shouldShowRecoveryCodes && (
|
||||
<div className="w-fit rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-4 pr-8">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 font-mono text-sm">
|
||||
{totpConfiguration.recoveryCodes.map((code, index) => (
|
||||
<div key={code} className="flex items-center text-mineshaft-200">
|
||||
<span className="w-8 text-right text-mineshaft-400">{index + 1}.</span>
|
||||
<span className="pl-2">{code}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<EmailServiceSetupModal
|
||||
isOpen={popUp.setUpEmail?.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteTotpConfig.isOpen}
|
||||
title="Are you sure you want to delete the configured authenticator?"
|
||||
subTitle="This action is irreversible. You’ll have to go through the setup process to enable it again."
|
||||
title="Remove mobile authenticator?"
|
||||
subTitle="This action is irreversible. You'll have to go through the setup process to enable it again."
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteTotpConfig", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={handleTotpDeletion}
|
||||
/>
|
||||
|
||||
<RecoveryCodesDownload
|
||||
isOpen={popUp.downloadRecoveryCodes?.isOpen || false}
|
||||
onClose={() => handlePopUpClose("downloadRecoveryCodes")}
|
||||
recoveryCodes={totpRegistration?.recoveryCodes || []}
|
||||
onDownloadComplete={() => handlePopUpClose("downloadRecoveryCodes")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user