Merge branch 'main' into ENG-3723

This commit is contained in:
x032205
2025-09-27 01:58:44 -04:00
162 changed files with 7033 additions and 424 deletions
@@ -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();
@@ -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}`);
}
@@ -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 =
@@ -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) {
@@ -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
})
);
@@ -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>
);
};
@@ -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) {
@@ -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
});
@@ -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}
@@ -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
@@ -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}
@@ -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>
);
};
@@ -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
@@ -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}
@@ -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 (
@@ -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>
);
};
@@ -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"
@@ -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) => {
@@ -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"
/>
@@ -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&apos;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")}
/>
</>
);
};