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
+155 -74
View File
@@ -5,10 +5,12 @@ import { t } from "i18next";
import Error from "@app/components/basic/Error";
import TotpRegistration from "@app/components/mfa/TotpRegistration";
import { createNotification } from "@app/components/notifications";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Input } from "@app/components/v2";
import { useSendMfaToken } from "@app/hooks/api";
import { checkUserTotpMfa, verifyMfaToken } from "@app/hooks/api/auth/queries";
import { Button, Tooltip } from "@app/components/v2";
import { isInfisicalCloud } from "@app/helpers/platform";
import { useLogoutUser, useSendMfaToken } from "@app/hooks/api";
import { checkUserTotpMfa, verifyMfaToken, verifyRecoveryCode } from "@app/hooks/api/auth/queries";
import { MfaMethod } from "@app/hooks/api/auth/types";
// The style for the verification code input
@@ -17,10 +19,10 @@ const codeInputProps = {
fontFamily: "monospace",
margin: "4px",
MozAppearance: "textfield",
width: "48px",
width: "55px",
borderRadius: "5px",
fontSize: "24px",
height: "48px",
height: "55px",
paddingLeft: "7",
backgroundColor: "#0d1117",
color: "white",
@@ -60,11 +62,13 @@ type Props = {
export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Props) => {
const [mfaCode, setMfaCode] = useState("");
const [showRecoveryCodeInput, setShowRecoveryCodeInput] = useState(false);
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [isLoadingResend, setIsLoadingResend] = useState(false);
const [triesLeft, setTriesLeft] = useState<number | undefined>(undefined);
const [shouldShowTotpRegistration, setShouldShowTotpRegistration] = useState(false);
const logout = useLogoutUser(true);
const sendMfaToken = useSendMfaToken();
@@ -79,35 +83,57 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
}
}, []);
const getExpectedCodeLength = () => {
if (method === MfaMethod.EMAIL) return 6;
if (method === MfaMethod.TOTP) return showRecoveryCodeInput ? 8 : 6;
return 6;
};
const isCodeComplete = mfaCode.length === getExpectedCodeLength();
const verifyMfa = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!mfaCode.trim() || !isCodeComplete) return;
setIsLoading(true);
try {
const { token } = await verifyMfaToken({
email,
mfaCode,
mfaMethod: method
});
let result;
if (method === MfaMethod.TOTP && showRecoveryCodeInput) {
result = await verifyRecoveryCode(mfaCode.trim());
} else {
result = await verifyMfaToken({
email,
mfaCode: mfaCode.trim(),
mfaMethod: method
});
}
SecurityClient.setMfaToken("");
SecurityClient.setToken(token);
SecurityClient.setToken(result.token);
await successCallback();
if (closeMfa) {
closeMfa();
}
} catch {
if (triesLeft) {
setTriesLeft((left) => {
if (triesLeft === 1) {
navigate({ to: "/" });
SecurityClient.setMfaToken("");
SecurityClient.setToken("");
}
return (left as number) - 1;
});
if (typeof triesLeft === "number") {
const newTriesLeft = triesLeft - 1;
setTriesLeft(newTriesLeft);
if (newTriesLeft <= 0) {
createNotification({
text: "User is temporary locked due to multiple failed login attempts. Try again later. You can also reset your password now to proceed.",
type: "error"
});
setIsLoading(false);
SecurityClient.setMfaToken("");
SecurityClient.setToken("");
SecurityClient.setSignupToken("");
await logout.mutateAsync();
navigate({ to: "/login" });
return;
}
} else {
setTriesLeft(2);
}
@@ -147,7 +173,7 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
}
return (
<div className="mx-auto w-max pb-4 pt-4 md:mb-16 md:px-8">
<div className="mx-auto w-max pb-6 pt-6 md:mb-16 md:px-8">
{!hideLogo && (
<Link to="/">
<div className="mb-4 flex justify-center">
@@ -162,79 +188,134 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
</>
)}
{method === MfaMethod.TOTP && (
<>
<p className="text-l mb-4 flex max-w-xs justify-center text-center font-bold text-bunker-100">
Authenticator MFA Required
<div className="mb-8 text-center">
<h2 className="mb-3 text-xl font-semibold text-bunker-100">Two-Factor Authentication</h2>
<p className="mx-auto max-w-md text-sm leading-relaxed text-bunker-300">
{showRecoveryCodeInput
? "Enter one of your backup recovery codes"
: "Enter the verification code from your authenticator app"}
</p>
<p className="text-l flex max-w-xs justify-center text-center text-bunker-300">
Open the authenticator app on your mobile device to get your verification code or enter
a recovery code.
</p>
</>
</div>
)}
<form onSubmit={verifyMfa}>
<div className="mx-auto hidden w-max min-w-[20rem] md:block">
<div className="mx-auto hidden md:block" style={{ minWidth: "600px" }}>
{method === MfaMethod.EMAIL && (
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mb-2 mt-6"
{...codeInputProps}
/>
<div className="flex justify-center">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mb-2 mt-6"
{...codeInputProps}
/>
</div>
)}
{method === MfaMethod.TOTP && (
<div className="mb-4 mt-6">
<Input value={mfaCode} onChange={(e) => setMfaCode(e.target.value)} />
<div className="mb-6 mt-8 flex justify-center">
<ReactCodeInput
key={showRecoveryCodeInput ? "recovery" : "totp"}
name=""
inputMode="tel"
type="text"
fields={showRecoveryCodeInput ? 8 : 6}
onChange={setMfaCode}
className="mb-2"
{...codeInputProps}
/>
</div>
)}
</div>
<div className="mx-auto mt-4 block w-max min-w-[18rem] md:hidden">
<div className="mx-auto mt-4 block md:hidden" style={{ minWidth: "400px" }}>
{method === MfaMethod.EMAIL && (
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mb-2 mt-2"
{...codeInputPropsPhone}
/>
<div className="flex justify-center">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mb-2 mt-2"
{...codeInputPropsPhone}
/>
</div>
)}
{method === MfaMethod.TOTP && (
<div className="mb-4 mt-2">
<Input value={mfaCode} onChange={(e) => setMfaCode(e.target.value)} />
<div className="mb-6 mt-4 flex justify-center">
<ReactCodeInput
key={showRecoveryCodeInput ? "recovery-mobile" : "totp-mobile"}
name=""
inputMode="tel"
type="text"
fields={showRecoveryCodeInput ? 8 : 6}
onChange={setMfaCode}
className="mb-2"
{...codeInputPropsPhone}
/>
</div>
)}
</div>
{typeof triesLeft === "number" && (
<Error text={`Invalid code. You have ${triesLeft} attempt(s) remaining.`} />
)}
<div className="mx-auto mt-2 flex w-1/4 min-w-[20rem] max-w-xs flex-col items-center justify-center text-center text-sm md:max-w-md md:text-left lg:w-[19%]">
<div className="text-l w-full py-1 text-lg">
<Button
size="sm"
type="submit"
isFullWidth
className="h-14"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
>
{String(t("mfa.verify"))}
</Button>
</div>
<div className="mx-auto mt-6 flex w-full max-w-sm flex-col items-center justify-center text-center">
<Button
size="md"
type="submit"
isFullWidth
className="h-11 rounded-lg font-medium shadow-sm transition-all duration-200 hover:shadow-md"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
isDisabled={!isCodeComplete || (typeof triesLeft === "number" && triesLeft <= 0)}
>
{String(t("mfa.verify"))}
</Button>
</div>
</form>
{method === MfaMethod.TOTP && (
<div className="mt-2 flex flex-row justify-center text-sm text-bunker-400">
<Link to="/verify-email">
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
Lost your recovery codes? Reset your account
</span>
</Link>
<div className="mt-6 flex flex-col items-center gap-4 text-sm">
<button
type="button"
onClick={() => {
setShowRecoveryCodeInput(!showRecoveryCodeInput);
setMfaCode("");
}}
className="text-bunker-400 transition-colors duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"
>
{showRecoveryCodeInput ? "Use authenticator code" : "Use a recovery code"}
</button>
<div className="text-center text-sm">
<Tooltip
position="bottom"
content={
<div className="max-w-xs text-center text-xs">
{isInfisicalCloud() ? (
<>
<div className="mb-2">Account Recovery Required</div>
<div className="mb-2 text-gray-300">
Contact support with valid proof of account ownership to initiate recovery
</div>
<div className="mt-1">support@infisical.com</div>
</>
) : (
<>
<div className="mb-2">Account Recovery Required</div>
<div className="text-gray-300">
Contact your instance administrator with valid proof of account ownership to
initiate recovery
</div>
</>
)}
</div>
}
>
<span className="cursor-help text-bunker-400 transition-colors duration-200 hover:text-bunker-200">
Lost your recovery codes?
</span>
</Tooltip>
</div>
</div>
)}
{method === MfaMethod.EMAIL && (
@@ -0,0 +1,111 @@
import { useState } from "react";
import { faCopy, faDownload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, Modal, ModalContent } from "../v2";
type Props = {
isOpen: boolean;
onClose: () => void;
recoveryCodes: string[];
onDownloadComplete: () => void;
};
export const RecoveryCodesDownload = ({
isOpen,
onClose,
recoveryCodes,
onDownloadComplete
}: Props) => {
const [hasDownloaded, setHasDownloaded] = useState(false);
const [copied, setCopied] = useState(false);
const downloadRecoveryCodes = () => {
const content = [...recoveryCodes].join("\n");
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `infisical-recovery-codes-${new Date().toISOString().split("T")[0]}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setHasDownloaded(true);
};
const copyToClipboard = async () => {
const text = recoveryCodes.join("\n");
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy recovery codes:", err);
}
};
const handleClose = () => {
if (hasDownloaded) {
onDownloadComplete();
onClose();
}
};
return (
<Modal isOpen={isOpen} onOpenChange={hasDownloaded ? handleClose : () => {}}>
<ModalContent title="Recovery Codes" className="max-w-md">
<div className="space-y-4">
<div className="rounded border border-yellow bg-yellow/10 p-2 px-3 text-xs text-yellow">
Save these codes securely. Each can only be used once.
</div>
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="grid grid-cols-2 gap-x-6 gap-y-2 font-mono text-sm">
{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 className="flex gap-3">
<Button
onClick={downloadRecoveryCodes}
className="flex flex-1 items-center justify-center gap-2"
colorSchema="primary"
variant="solid"
>
<FontAwesomeIcon icon={faDownload} className="mr-2 h-4 w-4" />
Download
</Button>
<Button
onClick={copyToClipboard}
className="flex flex-1 items-center justify-center gap-2"
colorSchema="secondary"
variant="outline"
>
<FontAwesomeIcon icon={faCopy} className="mr-2 h-4 w-4" />
{copied ? "Copied!" : "Copy"}
</Button>
</div>
{hasDownloaded ? (
<p className="text-center text-xs text-mineshaft-400">
Recovery codes downloaded. You can now close this modal.
</p>
) : (
<p className="text-center text-xs text-mineshaft-400">
Download the recovery codes to continue.
</p>
)}
</div>
</ModalContent>
</Modal>
);
};
@@ -7,6 +7,7 @@ import { useVerifyUserTotpRegistration } from "@app/hooks/api/users/mutation";
import { createNotification } from "../notifications";
import { Button, ContentLoader, Input } from "../v2";
import { RecoveryCodesDownload } from "./RecoveryCodesDownload";
type Props = {
onComplete?: () => Promise<void>;
@@ -19,20 +20,39 @@ const TotpRegistration = ({ onComplete, shouldCenterQr }: Props) => {
useVerifyUserTotpRegistration();
const [qrCodeUrl, setQrCodeUrl] = useState("");
const [totp, setTotp] = useState("");
const [showRecoveryModal, setShowRecoveryModal] = useState(false);
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
const handleTotpVerify = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
await verifyUserTotp({
totp
});
try {
const result = await verifyUserTotp({
totp
});
createNotification({
text: "Successfully configured mobile authenticator",
type: "success"
});
createNotification({
text: "Successfully configured mobile authenticator",
type: "success"
});
if (result.recoveryCodes && result.recoveryCodes.length > 0) {
setRecoveryCodes(result.recoveryCodes);
setShowRecoveryModal(true);
} else if (onComplete) {
onComplete();
}
} catch {
createNotification({
text: "Failed to verify TOTP code",
type: "error"
});
}
};
const handleRecoveryDownloadComplete = async () => {
setShowRecoveryModal(false);
if (onComplete) {
onComplete();
await onComplete();
}
};
@@ -52,28 +72,37 @@ const TotpRegistration = ({ onComplete, shouldCenterQr }: Props) => {
}
return (
<div className="flex max-w-lg flex-col text-bunker-200">
<div className="mb-8">
1. Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR
code.
</div>
<div className={twMerge("mb-8 flex items-center", shouldCenterQr && "justify-center")}>
<img src={qrCodeUrl} alt="registration-qr" />
</div>
<form onSubmit={handleTotpVerify}>
<div className="mb-4">2. Enter the resulting verification code</div>
<div className="mb-4 flex flex-row gap-2">
<Input
onChange={(e) => setTotp(e.target.value)}
value={totp}
placeholder="Verification code"
/>
<Button isLoading={isVerifyLoading} type="submit">
Enable MFA
</Button>
<>
<div className="flex max-w-lg flex-col text-bunker-200">
<div className="mb-8">
1. Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR
code.
</div>
</form>
</div>
<div className={twMerge("mb-8 flex items-center", shouldCenterQr && "justify-center")}>
<img src={qrCodeUrl} alt="registration-qr" />
</div>
<form onSubmit={handleTotpVerify}>
<div className="mb-4">2. Enter the resulting verification code</div>
<div className="mb-4 flex flex-row gap-2">
<Input
onChange={(e) => setTotp(e.target.value)}
value={totp}
placeholder="Verification code"
/>
<Button isLoading={isVerifyLoading} type="submit">
Enable MFA
</Button>
</div>
</form>
</div>
<RecoveryCodesDownload
isOpen={showRecoveryModal}
onClose={() => setShowRecoveryModal(false)}
recoveryCodes={recoveryCodes}
onDownloadComplete={handleRecoveryDownloadComplete}
/>
</>
);
};
@@ -0,0 +1,38 @@
import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay";
import { ViewRotationGeneratedCredentialsDisplay } from "./shared";
import { TRedisCredentialsRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/redis-credentials-rotation";
type Props = {
generatedCredentialsResponse: TRedisCredentialsRotationGeneratedCredentialsResponse;
};
export const ViewRedisCredentialsRotationGeneratedCredentials = ({
generatedCredentialsResponse: { generatedCredentials, activeIndex }
}: Props) => {
const inactiveIndex = activeIndex === 0 ? 1 : 0;
const activeCredentials = generatedCredentials[activeIndex];
const inactiveCredentials = generatedCredentials[inactiveIndex];
return (
<ViewRotationGeneratedCredentialsDisplay
activeCredentials={
<>
<CredentialDisplay label="Username">{activeCredentials?.username}</CredentialDisplay>
<CredentialDisplay isSensitive label="Password">
{activeCredentials?.password}
</CredentialDisplay>
</>
}
inactiveCredentials={
<>
<CredentialDisplay label="Username">{inactiveCredentials?.username}</CredentialDisplay>
<CredentialDisplay isSensitive label="Password">
{inactiveCredentials?.password}
</CredentialDisplay>
</>
}
/>
);
};
@@ -23,6 +23,7 @@ import {
import { ViewSqlCredentialsRotationGeneratedCredentials } from "./shared";
import { ViewAwsIamUserSecretRotationGeneratedCredentials } from "./ViewAwsIamUserSecretRotationGeneratedCredentials";
import { ViewOktaClientSecretRotationGeneratedCredentials } from "./ViewOktaClientSecretRotationGeneratedCredentials";
import { ViewRedisCredentialsRotationGeneratedCredentials } from "./ViewRedisCredentialsRotationGeneratedCredentials";
type Props = {
secretRotation?: TSecretRotationV2;
@@ -107,6 +108,13 @@ const Content = ({ secretRotation }: ContentProps) => {
/>
);
break;
case SecretRotation.RedisCredentials:
Component = (
<ViewRedisCredentialsRotationGeneratedCredentials
generatedCredentialsResponse={generatedCredentialsResponse}
/>
);
break;
default:
throw new Error("Unhandled View Generated Credential Rotation Type");
}
@@ -0,0 +1,197 @@
import { Controller, useFormContext } from "react-hook-form";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { FormControl, Input } from "@app/components/v2";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { DEFAULT_PASSWORD_REQUIREMENTS } from "../schemas/shared";
export const RedisCredentialsRotationParametersFields = () => {
const { control } = useFormContext<
TSecretRotationV2Form & {
type: SecretRotation.RedisCredentials;
}
>();
return (
<>
<div>
<Controller
control={control}
name="parameters.permissionScope"
defaultValue={""}
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipClassName="max-w-[40rem] w-full"
tooltipText={
<div className="flex flex-col gap-4">
<p>
This is the access control permissions that will be set for the issued Redis
users. The format must be a valid Redis ACL pattern.
</p>
<p>
The default value is{" "}
<code className="rounded bg-mineshaft-700 px-1 py-0.5 font-mono font-medium text-bunker-300">
~* +@all
</code>
. You can modify it to suit your needs.
</p>
<p>
For more information, please refer to the{" "}
<a
className="font-medium text-primary-500 underline hover:text-primary-600"
href="https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/"
target="_blank"
rel="noopener noreferrer"
>
Redis ACL documentation
</a>
.
</p>
</div>
}
label="Permission Scope"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="~* +@read @write" />
</FormControl>
)}
/>
</div>
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Password Requirements</span>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1 rounded border border-mineshaft-600 bg-mineshaft-700 px-3 pt-3">
<Controller
control={control}
name="parameters.passwordRequirements.length"
defaultValue={DEFAULT_PASSWORD_REQUIREMENTS.length}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password Length"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="The length of the password to generate"
>
<Input
type="number"
min={1}
max={250}
size="sm"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="parameters.passwordRequirements.required.digits"
defaultValue={DEFAULT_PASSWORD_REQUIREMENTS.required.digits}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Digit Count"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="Minimum number of digits"
>
<Input
type="number"
min={0}
size="sm"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="parameters.passwordRequirements.required.lowercase"
defaultValue={DEFAULT_PASSWORD_REQUIREMENTS.required.lowercase}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Lowercase Character Count"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="Minimum number of lowercase characters"
>
<Input
type="number"
min={0}
size="sm"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="parameters.passwordRequirements.required.uppercase"
defaultValue={DEFAULT_PASSWORD_REQUIREMENTS.required.uppercase}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Uppercase Character Count"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="Minimum number of uppercase characters"
>
<Input
type="number"
min={0}
size="sm"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="parameters.passwordRequirements.required.symbols"
defaultValue={DEFAULT_PASSWORD_REQUIREMENTS.required.symbols}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Symbol Count"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="Minimum number of symbols"
>
<Input
type="number"
min={0}
size="sm"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="parameters.passwordRequirements.allowedSymbols"
defaultValue={DEFAULT_PASSWORD_REQUIREMENTS.allowedSymbols}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Allowed Symbols"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="Symbols to use in generated password"
>
<Input
placeholder="-_.~!*"
size="sm"
{...field}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
</div>
</div>
</>
);
};
@@ -9,6 +9,7 @@ import { AzureClientSecretRotationParametersFields } from "./AzureClientSecretRo
import { LdapPasswordRotationParametersFields } from "./LdapPasswordRotationParametersFields";
import { OktaClientSecretRotationParametersFields } from "./OktaClientSecretRotationParametersFields";
import { SqlCredentialsRotationParametersFields } from "./shared";
import { RedisCredentialsRotationParametersFields } from "./RedisCredentialsRotationParametersFields";
const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
[SecretRotation.PostgresCredentials]: SqlCredentialsRotationParametersFields,
@@ -19,7 +20,8 @@ const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
[SecretRotation.AzureClientSecret]: AzureClientSecretRotationParametersFields,
[SecretRotation.LdapPassword]: LdapPasswordRotationParametersFields,
[SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationParametersFields,
[SecretRotation.OktaClientSecret]: OktaClientSecretRotationParametersFields
[SecretRotation.OktaClientSecret]: OktaClientSecretRotationParametersFields,
[SecretRotation.RedisCredentials]: RedisCredentialsRotationParametersFields
};
export const SecretRotationV2ParametersFields = () => {
@@ -0,0 +1,50 @@
import { useFormContext } from "react-hook-form";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { GenericFieldLabel } from "@app/components/v2";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { SecretRotationReviewSection } from "./shared";
export const RedisCredentialsRotationReviewFields = () => {
const { watch } = useFormContext<
TSecretRotationV2Form & {
type: SecretRotation.RedisCredentials;
}
>();
const [parameters, { username, password }] = watch(["parameters", "secretsMapping"]);
const { passwordRequirements, permissionScope } = parameters;
return (
<>
<SecretRotationReviewSection label="Parameters">
<GenericFieldLabel label="Permission Scope">{permissionScope}</GenericFieldLabel>
</SecretRotationReviewSection>
{passwordRequirements && (
<SecretRotationReviewSection label="Password Requirements">
<GenericFieldLabel label="Length">{passwordRequirements.length}</GenericFieldLabel>
<GenericFieldLabel label="Minimum Digits">
{passwordRequirements.required.digits}
</GenericFieldLabel>
<GenericFieldLabel label="Minimum Lowercase Characters">
{passwordRequirements.required.lowercase}
</GenericFieldLabel>
<GenericFieldLabel label="Minimum Uppercase Characters">
{passwordRequirements.required.uppercase}
</GenericFieldLabel>
<GenericFieldLabel label="Minimum Symbols">
{passwordRequirements.required.symbols}
</GenericFieldLabel>
<GenericFieldLabel label="Allowed Symbols">
{passwordRequirements.allowedSymbols}
</GenericFieldLabel>
</SecretRotationReviewSection>
)}
<SecretRotationReviewSection label="Secrets Mapping">
<GenericFieldLabel label="Username">{username}</GenericFieldLabel>
<GenericFieldLabel label="Password">{password}</GenericFieldLabel>
</SecretRotationReviewSection>
</>
);
};
@@ -12,6 +12,7 @@ import { AzureClientSecretRotationReviewFields } from "./AzureClientSecretRotati
import { LdapPasswordRotationReviewFields } from "./LdapPasswordRotationReviewFields";
import { OktaClientSecretRotationReviewFields } from "./OktaClientSecretRotationReviewFields";
import { SqlCredentialsRotationReviewFields } from "./shared";
import { RedisCredentialsRotationReviewFields } from "./RedisCredentialsRotationReviewFields";
const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
[SecretRotation.PostgresCredentials]: SqlCredentialsRotationReviewFields,
@@ -22,7 +23,8 @@ const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
[SecretRotation.AzureClientSecret]: AzureClientSecretRotationReviewFields,
[SecretRotation.LdapPassword]: LdapPasswordRotationReviewFields,
[SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationReviewFields,
[SecretRotation.OktaClientSecret]: OktaClientSecretRotationReviewFields
[SecretRotation.OktaClientSecret]: OktaClientSecretRotationReviewFields,
[SecretRotation.RedisCredentials]: RedisCredentialsRotationReviewFields
};
export const SecretRotationV2ReviewFields = () => {
@@ -0,0 +1,58 @@
import { Controller, useFormContext } from "react-hook-form";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { FormControl, Input } from "@app/components/v2";
import { SecretRotation, useSecretRotationV2Option } from "@app/hooks/api/secretRotationsV2";
import { SecretsMappingTable } from "./shared";
export const RedisCredentialsRotationSecretsMappingFields = () => {
const { control } = useFormContext<
TSecretRotationV2Form & {
type: SecretRotation.RedisCredentials;
}
>();
const { rotationOption } = useSecretRotationV2Option(SecretRotation.RedisCredentials);
const items = [
{
name: "Username",
input: (
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input
value={value}
onChange={onChange}
placeholder={rotationOption?.template.secretsMapping.username}
/>
</FormControl>
)}
control={control}
name="secretsMapping.username"
/>
)
},
{
name: "Password",
input: (
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input
value={value}
onChange={onChange}
placeholder={rotationOption?.template.secretsMapping.password}
/>
</FormControl>
)}
control={control}
name="secretsMapping.password"
/>
)
}
];
return <SecretsMappingTable items={items} />;
};
@@ -9,6 +9,7 @@ import { AzureClientSecretRotationSecretsMappingFields } from "./AzureClientSecr
import { LdapPasswordRotationSecretsMappingFields } from "./LdapPasswordRotationSecretsMappingFields";
import { OktaClientSecretRotationSecretsMappingFields } from "./OktaClientSecretRotationSecretsMappingFields";
import { SqlCredentialsRotationSecretsMappingFields } from "./shared";
import { RedisCredentialsRotationSecretsMappingFields } from "./RedisCredentialsRotationSecretsMappingFields";
const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
[SecretRotation.PostgresCredentials]: SqlCredentialsRotationSecretsMappingFields,
@@ -19,7 +20,8 @@ const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
[SecretRotation.AzureClientSecret]: AzureClientSecretRotationSecretsMappingFields,
[SecretRotation.LdapPassword]: LdapPasswordRotationSecretsMappingFields,
[SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationSecretsMappingFields,
[SecretRotation.OktaClientSecret]: OktaClientSecretRotationSecretsMappingFields
[SecretRotation.OktaClientSecret]: OktaClientSecretRotationSecretsMappingFields,
[SecretRotation.RedisCredentials]: RedisCredentialsRotationSecretsMappingFields
};
export const SecretRotationV2SecretsMappingFields = () => {
@@ -12,6 +12,7 @@ import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/typ
import { OktaClientSecretRotationSchema } from "./okta-client-secret-rotation-schema";
import { OracleDBCredentialsRotationSchema } from "./oracledb-credentials-rotation-schema";
import { RedisCredentialsRotationSchema } from "./redis-credentials-rotation-schema";
export const SecretRotationV2FormSchema = (isUpdate: boolean) =>
z
@@ -25,7 +26,8 @@ export const SecretRotationV2FormSchema = (isUpdate: boolean) =>
OracleDBCredentialsRotationSchema,
LdapPasswordRotationSchema,
AwsIamUserSecretRotationSchema,
OktaClientSecretRotationSchema
OktaClientSecretRotationSchema,
RedisCredentialsRotationSchema
]),
z.object({ id: z.string().optional() })
)
@@ -0,0 +1,20 @@
import { z } from "zod";
import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { PasswordRequirementsSchema } from "./shared";
export const RedisCredentialsRotationSchema = z
.object({
type: z.literal(SecretRotation.RedisCredentials),
parameters: z.object({
passwordRequirements: PasswordRequirementsSchema.optional(),
permissionScope: z.string().trim().min(1, "Permission scope is required")
}),
secretsMapping: z.object({
username: z.string().trim().min(1, "Username required"),
password: z.string().trim().min(1, "Password required")
})
})
.merge(BaseSecretRotationSchema);
+4 -1
View File
@@ -12,6 +12,7 @@ type Props = {
isReadOnly?: boolean;
autoCapitalization?: boolean;
containerClassName?: string;
warning?: ReactNode;
};
const inputVariants = cva(
@@ -84,6 +85,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
size = "md",
isReadOnly,
autoCapitalization,
warning,
...props
},
ref
@@ -116,10 +118,11 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
onInput={handleInput}
className={twMerge(
leftIcon ? "pl-10" : "pl-2.5",
rightIcon ? "pr-10" : "pr-2.5",
rightIcon || warning ? "pr-10" : "pr-2.5",
inputVariants({ className, isError, size, isRounded, variant })
)}
/>
{Boolean(warning) && !rightIcon && warning}
{rightIcon && <span className="absolute right-0 mr-3">{rightIcon}</span>}
</div>
);
+74 -1
View File
@@ -1,7 +1,15 @@
import axios from "axios";
import { addSeconds, formatISO } from "date-fns";
import { createNotification } from "@app/components/notifications";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { getAuthToken, getMfaTempToken, getSignupTempToken } from "@app/hooks/api/reactQuery";
import { SessionStorageKeys } from "@app/const";
import {
getAuthToken,
getMfaTempToken,
getSignupTempToken,
setAuthToken
} from "@app/hooks/api/reactQuery";
export const apiRequest = axios.create({
baseURL: "/",
@@ -34,3 +42,68 @@ apiRequest.interceptors.request.use((config) => {
return config;
});
let isRedirecting = false;
const resetRedirectingFlag = () => {
isRedirecting = false;
};
apiRequest.interceptors.response.use(
(response) => response,
async (error) => {
const { response } = error;
if (response && (response.status === 401 || response.status === 403)) {
const currentToken = getAuthToken();
const isAuthenticatedRequest = Boolean(currentToken);
if (isAuthenticatedRequest && !isRedirecting) {
// Check if the error indicates token expiration
const errorMessage = response.data?.message || "";
const isTokenExpired = errorMessage
.toLowerCase()
.includes("your token has expired. please re-authenticate.");
if (isTokenExpired) {
isRedirecting = true;
try {
setAuthToken("");
SecurityClient.setToken("");
} catch (err) {
console.warn("Error clearing tokens:", err);
}
createNotification({
type: "error",
title: "Session Expired",
text: "Your session has expired. Redirecting to login page..."
});
try {
sessionStorage.setItem(
SessionStorageKeys.ORG_LOGIN_SUCCESS_REDIRECT_URL,
JSON.stringify({
expiry: formatISO(addSeconds(new Date(), 300)), // 5 minutes
data: window.location.href
})
);
} catch (err) {
console.warn("Could not save redirect URL to sessionStorage:", err);
}
setTimeout(() => {
window.location.href = "/login";
}, 2000); // 2 seconds to read the notification
setTimeout(resetRedirectingFlag, 3000);
return Promise.reject(new Error("Session expired - redirecting to login"));
}
}
}
return Promise.reject(error);
}
);
+4 -1
View File
@@ -36,6 +36,7 @@ import {
OnePassConnectionMethod,
OracleDBConnectionMethod,
PostgresConnectionMethod,
RedisConnectionMethod,
TAppConnection,
TeamCityConnectionMethod,
TerraformCloudConnectionMethod,
@@ -113,7 +114,8 @@ export const APP_CONNECTION_MAP: Record<
name: "Netlify",
image: "Netlify.png"
},
[AppConnection.Okta]: { name: "Okta", image: "Okta.png" }
[AppConnection.Okta]: { name: "Okta", image: "Okta.png" },
[AppConnection.Redis]: { name: "Redis", image: "Redis.png" }
};
export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => {
@@ -155,6 +157,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
case MySqlConnectionMethod.UsernameAndPassword:
case OracleDBConnectionMethod.UsernameAndPassword:
case AzureADCSConnectionMethod.UsernamePassword:
case RedisConnectionMethod.UsernameAndPassword:
return { name: "Username & Password", icon: faLock };
case HCVaultConnectionMethod.AccessToken:
case TeamCityConnectionMethod.AccessToken:
+9 -2
View File
@@ -49,6 +49,11 @@ export const SECRET_ROTATION_MAP: Record<
name: "Okta Client Secret",
image: "Okta.png",
size: 50
},
[SecretRotation.RedisCredentials]: {
name: "Redis Credentials",
image: "Redis.png",
size: 50
}
};
@@ -61,7 +66,8 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record<SecretRotation, AppConnectio
[SecretRotation.AzureClientSecret]: AppConnection.AzureClientSecrets,
[SecretRotation.LdapPassword]: AppConnection.LDAP,
[SecretRotation.AwsIamUserSecret]: AppConnection.AWS,
[SecretRotation.OktaClientSecret]: AppConnection.Okta
[SecretRotation.OktaClientSecret]: AppConnection.Okta,
[SecretRotation.RedisCredentials]: AppConnection.Redis
};
// if a rotation can potentially have downtime due to rotating a single credential set this to false
@@ -74,7 +80,8 @@ export const IS_ROTATION_DUAL_CREDENTIALS: Record<SecretRotation, boolean> = {
[SecretRotation.AzureClientSecret]: true,
[SecretRotation.LdapPassword]: false,
[SecretRotation.AwsIamUserSecret]: true,
[SecretRotation.OktaClientSecret]: true
[SecretRotation.OktaClientSecret]: true,
[SecretRotation.RedisCredentials]: true
};
export const getRotateAtLocal = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"]) => {
@@ -36,5 +36,6 @@ export enum AppConnection {
Supabase = "supabase",
DigitalOcean = "digital-ocean",
Netlify = "netlify",
Okta = "okta"
Okta = "okta",
Redis = "redis"
}
@@ -168,6 +168,10 @@ export type TAzureAdCsConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.AzureADCS;
};
export type TRedisConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.Redis;
};
export type TAppConnectionOption =
| TAwsConnectionOption
| TGitHubConnectionOption
@@ -247,4 +251,5 @@ export type TAppConnectionOptionMap = {
[AppConnection.Netlify]: TNetlifyConnectionOption;
[AppConnection.Okta]: TOktaConnectionOption;
[AppConnection.AzureADCS]: TAzureAdCsConnectionOption;
[AppConnection.Redis]: TRedisConnectionOption;
};
@@ -31,6 +31,7 @@ import { TOktaConnection } from "./okta-connection";
import { TOracleDBConnection } from "./oracledb-connection";
import { TPostgresConnection } from "./postgres-connection";
import { TRailwayConnection } from "./railway-connection";
import { TRedisConnection } from "./redis-connection";
import { TRenderConnection } from "./render-connection";
import { TSupabaseConnection } from "./supabase-connection";
import { TTeamCityConnection } from "./teamcity-connection";
@@ -68,6 +69,7 @@ export * from "./okta-connection";
export * from "./oracledb-connection";
export * from "./postgres-connection";
export * from "./railway-connection";
export * from "./redis-connection";
export * from "./render-connection";
export * from "./supabase-connection";
export * from "./teamcity-connection";
@@ -114,7 +116,8 @@ export type TAppConnection =
| TSupabaseConnection
| TDigitalOceanConnection
| TNetlifyConnection
| TOktaConnection;
| TOktaConnection
| TRedisConnection;
export type TAvailableAppConnection = Pick<TAppConnection, "name" | "id" | "projectId">;
@@ -0,0 +1,21 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
export enum RedisConnectionMethod {
UsernameAndPassword = "username-and-password"
}
export type TRedisConnectionCredentials = {
host: string;
port: number;
username: string;
password?: string;
sslEnabled: boolean;
sslRejectUnauthorized: boolean;
sslCertificate?: string;
};
export type TRedisConnection = TRootAppConnection & { app: AppConnection.Redis } & {
method: RedisConnectionMethod.UsernameAndPassword;
credentials: TRedisConnectionCredentials;
};
+7
View File
@@ -183,6 +183,13 @@ export const useVerifyMfaToken = () => {
});
};
export const verifyRecoveryCode = async (recoveryCode: string) => {
const { data } = await apiRequest.post("/api/v2/auth/mfa/verify/recovery-code", {
recoveryCode
});
return data;
};
export const verifySignupInvite = async (details: VerifySignupInviteDTO) => {
const { data } = await apiRequest.post("/api/v1/invite-org/verify", details);
return data;
@@ -29,6 +29,7 @@ export enum DynamicSecretProviders {
MongoDB = "mongo-db",
RabbitMq = "rabbit-mq",
AzureEntraId = "azure-entra-id",
AzureSqlDatabase = "azure-sql-database",
Ldap = "ldap",
SapHana = "sap-hana",
Snowflake = "snowflake",
@@ -242,6 +243,34 @@ export type TDynamicSecretProvider =
clientSecret: string;
};
}
| {
type: DynamicSecretProviders.AzureSqlDatabase;
inputs: {
host: string;
port: number;
database: string;
masterDatabase?: string;
username: string;
password: string;
passwordRequirements?: {
length: number;
required: {
lowercase: number;
uppercase: number;
digits: number;
symbols: number;
};
allowedSymbols?: string;
};
masterCreationStatement: string;
creationStatement: string;
revocationStatement: string;
renewStatement?: string;
ca?: string;
sslEnabled?: boolean;
gatewayId?: string;
};
}
| {
type: DynamicSecretProviders.Ldap;
inputs: {
@@ -7,7 +7,8 @@ export enum SecretRotation {
AzureClientSecret = "azure-client-secret",
LdapPassword = "ldap-password",
AwsIamUserSecret = "aws-iam-user-secret",
OktaClientSecret = "okta-client-secret"
OktaClientSecret = "okta-client-secret",
RedisCredentials = "redis-credentials"
}
export enum SecretRotationStatus {
@@ -44,6 +44,11 @@ import {
TOracleDBCredentialsRotation,
TOracleDBCredentialsRotationGeneratedCredentialsResponse
} from "./oracledb-credentials-rotation";
import {
TRedisCredentialsRotation,
TRedisCredentialsRotationGeneratedCredentialsResponse,
TRedisCredentialsRotationOption
} from "./redis-credentials-rotation";
export type TSecretRotationV2 = (
| TPostgresCredentialsRotation
@@ -55,6 +60,7 @@ export type TSecretRotationV2 = (
| TLdapPasswordRotation
| TAwsIamUserSecretRotation
| TOktaClientSecretRotation
| TRedisCredentialsRotation
) & {
secrets: (SecretV3RawSanitized | null)[];
};
@@ -65,7 +71,8 @@ export type TSecretRotationV2Option =
| TAzureClientSecretRotationOption
| TLdapPasswordRotationOption
| TAwsIamUserSecretRotationOption
| TOktaClientSecretRotationOption;
| TOktaClientSecretRotationOption
| TRedisCredentialsRotationOption;
export type TListSecretRotationV2Options = { secretRotationOptions: TSecretRotationV2Option[] };
@@ -80,7 +87,8 @@ export type TViewSecretRotationGeneratedCredentialsResponse =
| TAzureClientSecretRotationGeneratedCredentialsResponse
| TLdapPasswordRotationGeneratedCredentialsResponse
| TAwsIamUserSecretRotationGeneratedCredentialsResponse
| TOktaClientSecretRotationGeneratedCredentialsResponse;
| TOktaClientSecretRotationGeneratedCredentialsResponse
| TRedisCredentialsRotationGeneratedCredentialsResponse;
export type TCreateSecretRotationV2DTO = DiscriminativePick<
TSecretRotationV2,
@@ -133,6 +141,7 @@ export type TSecretRotationOptionMap = {
[SecretRotation.LdapPassword]: TLdapPasswordRotationOption;
[SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationOption;
[SecretRotation.OktaClientSecret]: TOktaClientSecretRotationOption;
[SecretRotation.RedisCredentials]: TRedisCredentialsRotationOption;
};
export type TSecretRotationGeneratedCredentialsResponseMap = {
@@ -145,4 +154,5 @@ export type TSecretRotationGeneratedCredentialsResponseMap = {
[SecretRotation.LdapPassword]: TLdapPasswordRotationGeneratedCredentialsResponse;
[SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationGeneratedCredentialsResponse;
[SecretRotation.OktaClientSecret]: TOktaClientSecretRotationGeneratedCredentialsResponse;
[SecretRotation.RedisCredentials]: TRedisCredentialsRotationGeneratedCredentialsResponse;
};
@@ -0,0 +1,39 @@
import { TPasswordRequirements } from "@app/components/secret-rotations-v2/forms/schemas/shared";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import {
TSecretRotationV2Base,
TSecretRotationV2GeneratedCredentialsResponseBase
} from "@app/hooks/api/secretRotationsV2/types/shared";
export type TRedisCredentialsRotation = TSecretRotationV2Base & {
type: SecretRotation.RedisCredentials;
parameters: {
passwordRequirements?: TPasswordRequirements;
permissionScope: string;
};
secretsMapping: {
username: string;
password: string;
};
};
export type TRedisCredentialsRotationGeneratedCredentials = {
username: string;
password: string;
};
export type TRedisCredentialsRotationGeneratedCredentialsResponse =
TSecretRotationV2GeneratedCredentialsResponseBase<
SecretRotation.RedisCredentials,
TRedisCredentialsRotationGeneratedCredentials
>;
export type TRedisCredentialsRotationOption = {
name: string;
type: SecretRotation.RedisCredentials;
connection: AppConnection.Redis;
template: {
secretsMapping: TRedisCredentialsRotation["secretsMapping"];
};
};
@@ -0,0 +1,2 @@
export type { CalculateUpgradePathParams, GitHubVersion, UpgradePathResult } from "./queries";
export { useCalculateUpgradePath, useGetUpgradePathVersions } from "./queries";
@@ -0,0 +1,75 @@
import { useMutation, useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
export interface GitHubVersion {
tagName: string;
name: string;
publishedAt: string;
prerelease: boolean;
draft: boolean;
}
export interface UpgradePathResult {
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 interface CalculateUpgradePathParams {
fromVersion: string;
toVersion: string;
}
const upgradePathKeys = {
all: ["upgrade-path"] as const,
versions: () => [...upgradePathKeys.all, "versions"] as const,
calculate: (params: CalculateUpgradePathParams) =>
[...upgradePathKeys.all, "calculate", params] as const
};
export const useGetUpgradePathVersions = (
options?: Omit<UseQueryOptions<{ versions: GitHubVersion[] }>, "queryKey" | "queryFn">
) => {
return useQuery({
queryKey: upgradePathKeys.versions(),
queryFn: async () => {
const { data } = await apiRequest.get<{ versions: GitHubVersion[] }>(
"/api/v1/upgrade-path/versions"
);
return data;
},
...options
});
};
export const useCalculateUpgradePath = () => {
return useMutation({
mutationFn: async (params: CalculateUpgradePathParams): Promise<UpgradePathResult> => {
const { data } = await apiRequest.post<UpgradePathResult>(
"/api/v1/upgrade-path/calculate",
params
);
return data;
}
});
};
+8 -5
View File
@@ -77,13 +77,16 @@ export const useUpdateUserProjectFavorites = () => {
};
export const useVerifyUserTotpRegistration = () => {
return useMutation({
return useMutation<{ recoveryCodes: string[] }, unknown, { totp: string }>({
mutationFn: async ({ totp }: { totp: string }) => {
await apiRequest.post("/api/v1/user/me/totp/verify", {
totp
});
const { data } = await apiRequest.post<{ recoveryCodes: string[] }>(
"/api/v1/user/me/totp/verify",
{
totp
}
);
return {};
return data;
}
});
};
+3 -2
View File
@@ -508,7 +508,7 @@ export const useListUserGroupMemberships = (username: string) => {
});
};
export const useGetUserTotpRegistration = () => {
export const useGetUserTotpRegistration = (options?: { enabled?: boolean }) => {
return useQuery({
queryKey: userKeys.totpRegistration,
queryFn: async () => {
@@ -517,7 +517,8 @@ export const useGetUserTotpRegistration = () => {
);
return data;
}
},
enabled: options?.enabled ?? true
});
};
@@ -13,6 +13,7 @@ import {
faInfoCircle,
faServer,
faSignOut,
faToolbox,
faUser,
faUsers
} from "@fortawesome/free-solid-svg-icons";
@@ -104,6 +105,11 @@ export const INFISICAL_SUPPORT_OPTIONS = [
<FontAwesomeIcon key={5} className="pr-4 text-sm" icon={faUsers} />,
"Instance Admins",
() => "server-admins"
],
[
<FontAwesomeIcon key={6} className="pr-4 text-sm" icon={faToolbox} />,
"Version Upgrade Tool",
() => "/upgrade-path"
]
] as const;
@@ -345,6 +351,9 @@ export const Navbar = () => {
if (url === "server-admins" && isInfisicalCloud()) {
return null;
}
if (url === "upgrade-path" && isInfisicalCloud()) {
return null;
}
return (
<DropdownMenuItem key={url as string}>
{url === "server-admins" ? (
@@ -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")}
/>
</>
);
};
+28
View File
@@ -15,6 +15,7 @@ import { createFileRoute } from '@tanstack/react-router'
import { Route as rootRoute } from './pages/root'
import { Route as middlewaresRestrictLoginSignupImport } from './pages/middlewares/restrict-login-signup'
import { Route as middlewaresAuthenticateImport } from './pages/middlewares/authenticate'
import { Route as publicUpgradePathPageRouteImport } from './pages/public/UpgradePathPage/route'
import { Route as publicShareSecretPageRouteImport } from './pages/public/ShareSecretPage/route'
import { Route as authCliRedirectPageRouteImport } from './pages/auth/CliRedirectPage/route'
import { Route as indexImport } from './pages/index'
@@ -347,6 +348,14 @@ const middlewaresAuthenticateRoute = middlewaresAuthenticateImport.update({
getParentRoute: () => rootRoute,
} as any)
const publicUpgradePathPageRouteRoute = publicUpgradePathPageRouteImport.update(
{
id: '/upgrade-path',
path: '/upgrade-path',
getParentRoute: () => rootRoute,
} as any,
)
const publicShareSecretPageRouteRoute = publicShareSecretPageRouteImport.update(
{
id: '/share-secret',
@@ -2209,6 +2218,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof publicShareSecretPageRouteImport
parentRoute: typeof rootRoute
}
'/upgrade-path': {
id: '/upgrade-path'
path: '/upgrade-path'
fullPath: '/upgrade-path'
preLoaderRoute: typeof publicUpgradePathPageRouteImport
parentRoute: typeof rootRoute
}
'/_authenticate': {
id: '/_authenticate'
path: ''
@@ -5009,6 +5025,7 @@ export interface FileRoutesByFullPath {
'/': typeof indexRoute
'/cli-redirect': typeof authCliRedirectPageRouteRoute
'/share-secret': typeof publicShareSecretPageRouteRoute
'/upgrade-path': typeof publicUpgradePathPageRouteRoute
'': typeof organizationLayoutRouteWithChildren
'/password-setup': typeof authPasswordSetupPageRouteRoute
'/email-not-verified': typeof authEmailNotVerifiedPageRouteRoute
@@ -5249,6 +5266,7 @@ export interface FileRoutesByTo {
'/': typeof indexRoute
'/cli-redirect': typeof authCliRedirectPageRouteRoute
'/share-secret': typeof publicShareSecretPageRouteRoute
'/upgrade-path': typeof publicUpgradePathPageRouteRoute
'': typeof organizationLayoutRouteWithChildren
'/password-setup': typeof authPasswordSetupPageRouteRoute
'/email-not-verified': typeof authEmailNotVerifiedPageRouteRoute
@@ -5474,6 +5492,7 @@ export interface FileRoutesById {
'/': typeof indexRoute
'/cli-redirect': typeof authCliRedirectPageRouteRoute
'/share-secret': typeof publicShareSecretPageRouteRoute
'/upgrade-path': typeof publicUpgradePathPageRouteRoute
'/_authenticate': typeof middlewaresAuthenticateRouteWithChildren
'/_restrict-login-signup': typeof middlewaresRestrictLoginSignupRouteWithChildren
'/_authenticate/password-setup': typeof authPasswordSetupPageRouteRoute
@@ -5727,6 +5746,7 @@ export interface FileRouteTypes {
| '/'
| '/cli-redirect'
| '/share-secret'
| '/upgrade-path'
| ''
| '/password-setup'
| '/email-not-verified'
@@ -5966,6 +5986,7 @@ export interface FileRouteTypes {
| '/'
| '/cli-redirect'
| '/share-secret'
| '/upgrade-path'
| ''
| '/password-setup'
| '/email-not-verified'
@@ -6189,6 +6210,7 @@ export interface FileRouteTypes {
| '/'
| '/cli-redirect'
| '/share-secret'
| '/upgrade-path'
| '/_authenticate'
| '/_restrict-login-signup'
| '/_authenticate/password-setup'
@@ -6441,6 +6463,7 @@ export interface RootRouteChildren {
indexRoute: typeof indexRoute
authCliRedirectPageRouteRoute: typeof authCliRedirectPageRouteRoute
publicShareSecretPageRouteRoute: typeof publicShareSecretPageRouteRoute
publicUpgradePathPageRouteRoute: typeof publicUpgradePathPageRouteRoute
middlewaresAuthenticateRoute: typeof middlewaresAuthenticateRouteWithChildren
middlewaresRestrictLoginSignupRoute: typeof middlewaresRestrictLoginSignupRouteWithChildren
publicViewSecretRequestByIDPageRouteRoute: typeof publicViewSecretRequestByIDPageRouteRoute
@@ -6451,6 +6474,7 @@ const rootRouteChildren: RootRouteChildren = {
indexRoute: indexRoute,
authCliRedirectPageRouteRoute: authCliRedirectPageRouteRoute,
publicShareSecretPageRouteRoute: publicShareSecretPageRouteRoute,
publicUpgradePathPageRouteRoute: publicUpgradePathPageRouteRoute,
middlewaresAuthenticateRoute: middlewaresAuthenticateRouteWithChildren,
middlewaresRestrictLoginSignupRoute:
middlewaresRestrictLoginSignupRouteWithChildren,
@@ -6473,6 +6497,7 @@ export const routeTree = rootRoute
"/",
"/cli-redirect",
"/share-secret",
"/upgrade-path",
"/_authenticate",
"/_restrict-login-signup",
"/secret-request/secret/$secretRequestId",
@@ -6488,6 +6513,9 @@ export const routeTree = rootRoute
"/share-secret": {
"filePath": "public/ShareSecretPage/route.tsx"
},
"/upgrade-path": {
"filePath": "public/UpgradePathPage/route.tsx"
},
"/_authenticate": {
"filePath": "middlewares/authenticate.tsx",
"children": [
+1
View File
@@ -400,6 +400,7 @@ export const routes = rootRoute("root.tsx", [
route("/shared/secret/$secretId", "public/ViewSharedSecretByIDPage/route.tsx"),
route("/secret-request/secret/$secretRequestId", "public/ViewSecretRequestByIDPage/route.tsx"),
route("/share-secret", "public/ShareSecretPage/route.tsx"),
route("/upgrade-path", "public/UpgradePathPage/route.tsx"),
route("/cli-redirect", "auth/CliRedirectPage/route.tsx"),
middleware("restrict-login-signup.tsx", [
route("/admin/signup", "admin/SignUpPage/route.tsx"),