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