Merge pull request #2742 from Infisical/feat/totp-dynamic-secret

feat: TOTP dynamic secret provider
This commit is contained in:
Sheen
2024-11-21 12:00:12 +08:00
committed by GitHub
32 changed files with 1086 additions and 58 deletions
+17 -1
View File
@@ -28,7 +28,8 @@ export enum DynamicSecretProviders {
AzureEntraId = "azure-entra-id",
Ldap = "ldap",
SapHana = "sap-hana",
Snowflake = "snowflake"
Snowflake = "snowflake",
Totp = "totp"
}
export enum SqlProviders {
@@ -230,6 +231,21 @@ export type TDynamicSecretProvider =
revocationStatement: string;
renewStatement?: string;
};
}
| {
type: DynamicSecretProviders.Totp;
inputs:
| {
configType: "url";
url: string;
}
| {
configType: "manual";
secret: string;
period?: number;
algorithm?: string;
digits?: number;
};
};
export type TCreateDynamicSecretDTO = {
projectSlug: string;
@@ -11,7 +11,7 @@ import {
SiSnowflake
} from "react-icons/si";
import { faAws } from "@fortawesome/free-brands-svg-icons";
import { faDatabase } from "@fortawesome/free-solid-svg-icons";
import { faClock, faDatabase } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
@@ -31,6 +31,7 @@ import { RabbitMqInputForm } from "./RabbitMqInputForm";
import { RedisInputForm } from "./RedisInputForm";
import { SapHanaInputForm } from "./SapHanaInputForm";
import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm";
import { TotpInputForm } from "./TotpInputForm";
type Props = {
isOpen?: boolean;
@@ -110,6 +111,11 @@ const DYNAMIC_SECRET_LIST = [
icon: <SiSnowflake size="1.5rem" />,
provider: DynamicSecretProviders.Snowflake,
title: "Snowflake"
},
{
icon: <FontAwesomeIcon icon={faClock} size="lg" />,
provider: DynamicSecretProviders.Totp,
title: "TOTP"
}
];
@@ -405,6 +411,24 @@ export const CreateDynamicSecretForm = ({
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.Totp && (
<motion.div
key="dynamic-totp-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<TotpInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
</AnimatePresence>
</ModalContent>
</Modal>
@@ -0,0 +1,314 @@
import { Controller, useForm } from "react-hook-form";
import Link from "next/link";
import { faArrowUpRightFromSquare, faBookOpen } 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, Select, SelectItem } from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
enum ConfigType {
URL = "url",
MANUAL = "manual"
}
enum TotpAlgorithm {
SHA1 = "sha1",
SHA256 = "sha256",
SHA512 = "sha512"
}
const formSchema = z.object({
provider: z.discriminatedUnion("configType", [
z.object({
configType: z.literal(ConfigType.URL),
url: z
.string()
.url()
.trim()
.min(1)
.refine((val) => {
const urlObj = new URL(val);
const secret = urlObj.searchParams.get("secret");
return Boolean(secret);
}, "OTP URL must contain secret field")
}),
z.object({
configType: z.literal(ConfigType.MANUAL),
secret: z
.string()
.trim()
.min(1)
.transform((val) => val.replace(/\s+/g, "")),
period: z.number().optional(),
algorithm: z.nativeEnum(TotpAlgorithm).optional(),
digits: z.number().optional()
})
]),
name: z
.string()
.trim()
.min(1)
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
export const TotpInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
watch,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
provider: {
configType: ConfigType.URL
}
}
});
const selectedConfigType = watch("provider.configType");
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, provider }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isLoading) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.Totp, inputs: provider },
maxTTL: "24h",
name,
path: secretPath,
defaultTTL: "1m",
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch (err) {
createNotification({
type: "error",
text: err instanceof Error ? err.message : "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>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
<Link
href="https://infisical.com/docs/documentation/platform/dynamic-secrets/totp"
passHref
>
<a target="_blank" rel="noopener noreferrer">
<div className="ml-2 mb-1 inline-block rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="ml-1.5 mb-[0.07rem] text-xxs"
/>
</div>
</a>
</Link>
</div>
<div className="flex flex-col">
<Controller
control={control}
name="provider.configType"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Configuration Type"
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-full"
>
<Select
defaultValue={field.value}
{...field}
className="w-full"
onValueChange={(val) => {
field.onChange(val);
}}
dropdownContainerClassName="max-w-full"
>
<SelectItem value={ConfigType.URL} key="config-type-url">
URL
</SelectItem>
<SelectItem value={ConfigType.MANUAL} key="config-type-manual">
Manual
</SelectItem>
</Select>
</FormControl>
)}
/>
{selectedConfigType === ConfigType.URL && (
<Controller
control={control}
name="provider.url"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="OTP URL"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} placeholder="otpauth://" />
</FormControl>
)}
/>
)}
{selectedConfigType === ConfigType.MANUAL && (
<>
<Controller
control={control}
name="provider.secret"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Key"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex flex-row gap-2">
<Controller
control={control}
name="provider.period"
defaultValue={30}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Period"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
{...field}
type="number"
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.digits"
defaultValue={6}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Digits"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
{...field}
type="number"
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.algorithm"
defaultValue={TotpAlgorithm.SHA1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Algorithm"
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-full"
>
<Select
defaultValue={field.value}
{...field}
className="w-full"
dropdownContainerClassName="max-w-full"
onValueChange={(val) => {
field.onChange(val);
}}
>
<SelectItem value={TotpAlgorithm.SHA1} key="algorithm-sha-1">
SHA1
</SelectItem>
<SelectItem value={TotpAlgorithm.SHA256} key="algorithm-sha-256">
SHA256
</SelectItem>
<SelectItem value={TotpAlgorithm.SHA512} key="algorithm-sha-512">
SHA512
</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<p className="mb-8 text-sm font-normal text-gray-400">
The period, digits, and algorithm values can remain at their defaults unless
your TOTP provider specifies otherwise.
</p>
</>
)}
</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>
);
};
@@ -1,6 +1,6 @@
import { ReactNode } from "react";
import { ReactNode, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { faCheck, faClock, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { AnimatePresence, motion } from "framer-motion";
@@ -9,8 +9,16 @@ import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, IconButton, Input, SecretInput, Tooltip } from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
import {
Button,
FormControl,
IconButton,
Input,
SecretInput,
Spinner,
Tooltip
} from "@app/components/v2";
import { useTimedReset, useToggle } from "@app/hooks";
import { useCreateDynamicSecretLease } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
@@ -54,7 +62,76 @@ const OutputDisplay = ({
);
};
const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => {
const TotpOutputDisplay = ({
totp,
remainingSeconds,
triggerLeaseRegeneration
}: {
totp: string;
remainingSeconds: number;
triggerLeaseRegeneration: (details: { ttl?: string }) => Promise<void>;
}) => {
const [remainingTime, setRemainingTime] = useState(remainingSeconds);
const [shouldShowRegenerate, setShouldShowRegenerate] = useToggle(false);
useEffect(() => {
setRemainingTime(remainingSeconds);
setShouldShowRegenerate.off();
// Set up countdown interval
const intervalId = setInterval(() => {
setRemainingTime((prevTime) => {
if (prevTime <= 1) {
clearInterval(intervalId);
setShouldShowRegenerate.on();
return 0;
}
return prevTime - 1;
});
}, 1000);
// Cleanup interval on unmount or when totp changes
return () => clearInterval(intervalId);
}, [totp, remainingSeconds]);
return (
<div className="h-36">
<OutputDisplay label="Time-based one-time password" value={totp} />
{remainingTime > 0 ? (
<div
className={`ml-2 flex items-center text-sm ${
remainingTime < 10 ? "text-red-500" : "text-yellow-500"
} transition-colors duration-500`}
>
<FontAwesomeIcon className="mr-1" icon={faClock} size="sm" />
<span>
Expires in {remainingTime} {remainingTime > 1 ? "seconds" : "second"}
</span>
</div>
) : (
<div className="ml-2 flex items-center text-sm text-red-500">
<FontAwesomeIcon className="mr-1" icon={faClock} size="sm" />
Expired
</div>
)}
{shouldShowRegenerate && (
<Button
colorSchema="secondary"
className="mt-2"
onClick={() => triggerLeaseRegeneration({})}
>
Regenerate
</Button>
)}
</div>
);
};
const renderOutputForm = (
provider: DynamicSecretProviders,
data: unknown,
triggerLeaseRegeneration: (details: { ttl?: string }) => Promise<void>
) => {
if (
provider === DynamicSecretProviders.SqlDatabase ||
provider === DynamicSecretProviders.Cassandra ||
@@ -242,11 +319,29 @@ const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => {
);
}
if (provider === DynamicSecretProviders.Totp) {
const { TOTP, TIME_REMAINING } = data as {
TOTP: string;
TIME_REMAINING: number;
};
return (
<TotpOutputDisplay
totp={TOTP}
remainingSeconds={TIME_REMAINING}
triggerLeaseRegeneration={triggerLeaseRegeneration}
/>
);
}
return null;
};
const formSchema = z.object({
ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number")
ttl: z
.string()
.refine((val) => ms(val) > 0, "TTL must be a positive number")
.optional()
});
type TForm = z.infer<typeof formSchema>;
@@ -259,6 +354,8 @@ type Props = {
secretPath: string;
};
const PROVIDERS_WITH_AUTOGENERATE_SUPPORT = [DynamicSecretProviders.Totp];
export const CreateDynamicSecretLease = ({
onClose,
projectSlug,
@@ -277,6 +374,9 @@ export const CreateDynamicSecretLease = ({
ttl: "1h"
}
});
const [isPreloading, setIsPreloading] = useToggle(
PROVIDERS_WITH_AUTOGENERATE_SUPPORT.includes(provider)
);
const createDynamicSecretLease = useCreateDynamicSecretLease();
@@ -290,10 +390,13 @@ export const CreateDynamicSecretLease = ({
ttl,
dynamicSecretName
});
createNotification({
type: "success",
text: "Successfully leased dynamic secret"
});
setIsPreloading.off();
} catch (error) {
console.log(error);
createNotification({
@@ -303,8 +406,23 @@ export const CreateDynamicSecretLease = ({
}
};
const handleLeaseRegeneration = async (data: { ttl?: string }) => {
setIsPreloading.on();
handleDynamicSecretLeaseCreate(data);
};
useEffect(() => {
if (provider === DynamicSecretProviders.Totp) {
handleDynamicSecretLeaseCreate({});
}
}, [provider]);
const isOutputMode = Boolean(createDynamicSecretLease?.data);
if (isPreloading) {
return <Spinner className="mx-auto h-40 text-mineshaft-700" />;
}
return (
<div>
<AnimatePresence>
@@ -350,7 +468,11 @@ export const CreateDynamicSecretLease = ({
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
{renderOutputForm(provider, createDynamicSecretLease.data?.data)}
{renderOutputForm(
provider,
createDynamicSecretLease.data?.data,
handleLeaseRegeneration
)}
</motion.div>
)}
</AnimatePresence>
@@ -101,10 +101,20 @@ export const DynamicSecretListView = ({
role="button"
tabIndex={0}
onKeyDown={(evt) => {
// no lease view for TOTP because it's irrelevant
if (secret.type === DynamicSecretProviders.Totp) {
return;
}
if (evt.key === "Enter" && !isRevoking)
handlePopUpOpen("dynamicSecretLeases", secret.id);
}}
onClick={() => {
// no lease view for TOTP because it's irrelevant
if (secret.type === DynamicSecretProviders.Totp) {
return;
}
if (!isRevoking) {
handlePopUpOpen("dynamicSecretLeases", secret.id);
}
@@ -17,6 +17,7 @@ import { EditDynamicSecretRedisProviderForm } from "./EditDynamicSecretRedisProv
import { EditDynamicSecretSapHanaForm } from "./EditDynamicSecretSapHanaForm";
import { EditDynamicSecretSnowflakeForm } from "./EditDynamicSecretSnowflakeForm";
import { EditDynamicSecretSqlProviderForm } from "./EditDynamicSecretSqlProviderForm";
import { EditDynamicSecretTotpForm } from "./EditDynamicSecretTotpForm";
type Props = {
onClose: () => void;
@@ -276,6 +277,23 @@ export const EditDynamicSecretForm = ({
/>
</motion.div>
)}
{dynamicSecretDetails?.type === DynamicSecretProviders.Totp && (
<motion.div
key="totp-edit"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<EditDynamicSecretTotpForm
onClose={onClose}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecret={dynamicSecretDetails}
environment={environment}
/>
</motion.div>
)}
</AnimatePresence>
);
};
@@ -0,0 +1,318 @@
import { Controller, useForm } from "react-hook-form";
import Link from "next/link";
import { faArrowUpRightFromSquare, faBookOpen } 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, Select, SelectItem } from "@app/components/v2";
import { useUpdateDynamicSecret } from "@app/hooks/api";
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
enum ConfigType {
URL = "url",
MANUAL = "manual"
}
enum TotpAlgorithm {
SHA1 = "sha1",
SHA256 = "sha256",
SHA512 = "sha512"
}
const formSchema = z.object({
inputs: z
.discriminatedUnion("configType", [
z.object({
configType: z.literal(ConfigType.URL),
url: z
.string()
.url()
.trim()
.min(1)
.refine((val) => {
const urlObj = new URL(val);
const secret = urlObj.searchParams.get("secret");
return Boolean(secret);
}, "OTP URL must contain secret field")
}),
z.object({
configType: z.literal(ConfigType.MANUAL),
secret: z
.string()
.trim()
.min(1)
.transform((val) => val.replace(/\s+/g, "")),
period: z.number().optional(),
algorithm: z.nativeEnum(TotpAlgorithm).optional(),
digits: z.number().optional()
})
])
.optional(),
newName: z
.string()
.trim()
.min(1)
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onClose: () => void;
dynamicSecret: TDynamicSecret & { inputs: unknown };
secretPath: string;
projectSlug: string;
environment: string;
};
export const EditDynamicSecretTotpForm = ({
onClose,
dynamicSecret,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
watch,
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
values: {
newName: dynamicSecret.name,
inputs: dynamicSecret.inputs as TForm["inputs"]
}
});
const selectedConfigType = watch("inputs.configType");
const updateDynamicSecret = useUpdateDynamicSecret();
const handleUpdateDynamicSecret = async ({ inputs, newName }: TForm) => {
// wait till previous request is finished
if (updateDynamicSecret.isLoading) return;
try {
await updateDynamicSecret.mutateAsync({
name: dynamicSecret.name,
path: secretPath,
projectSlug,
environmentSlug: environment,
data: {
inputs,
newName: newName === dynamicSecret.name ? undefined : newName
}
});
onClose();
createNotification({
type: "success",
text: "Successfully updated dynamic secret"
});
} catch (err) {
createNotification({
type: "error",
text: err instanceof Error ? err.message : "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}
defaultValue=""
name="newName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
<Link
href="https://infisical.com/docs/documentation/platform/dynamic-secrets/totp"
passHref
>
<a target="_blank" rel="noopener noreferrer">
<div className="ml-2 mb-1 inline-block rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="ml-1.5 mb-[0.07rem] text-xxs"
/>
</div>
</a>
</Link>
</div>
<div className="flex flex-col">
<Controller
control={control}
name="inputs.configType"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Configuration Type"
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-full"
>
<Select
defaultValue={field.value}
{...field}
className="w-full"
onValueChange={(val) => {
field.onChange(val);
}}
dropdownContainerClassName="max-w-full"
>
<SelectItem value={ConfigType.URL} key="config-type-url">
URL
</SelectItem>
<SelectItem value={ConfigType.MANUAL} key="config-type-manual">
Manual
</SelectItem>
</Select>
</FormControl>
)}
/>
{selectedConfigType === ConfigType.URL && (
<Controller
control={control}
name="inputs.url"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="OTP URL"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
)}
{selectedConfigType === ConfigType.MANUAL && (
<>
<Controller
control={control}
name="inputs.secret"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Key"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex flex-row gap-2">
<Controller
control={control}
name="inputs.period"
defaultValue={30}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Period"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
{...field}
type="number"
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.digits"
defaultValue={6}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Digits"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
{...field}
type="number"
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.algorithm"
defaultValue={TotpAlgorithm.SHA1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Algorithm"
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-full"
>
<Select
defaultValue={field.value}
{...field}
className="w-full"
dropdownContainerClassName="max-w-full"
onValueChange={(val) => {
field.onChange(val);
}}
>
<SelectItem value={TotpAlgorithm.SHA1} key="algorithm-sha-1">
SHA1
</SelectItem>
<SelectItem value={TotpAlgorithm.SHA256} key="algorithm-sha-256">
SHA256
</SelectItem>
<SelectItem value={TotpAlgorithm.SHA512} key="algorithm-sha-512">
SHA512
</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<p className="mb-8 text-sm font-normal text-gray-400">
The period, digits, and algorithm values can remain at their defaults unless
your TOTP provider specifies otherwise.
</p>
</>
)}
</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={onClose}>
Cancel
</Button>
</div>
</form>
</div>
);
};