diff --git a/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx b/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx new file mode 100644 index 0000000000..62002b492f --- /dev/null +++ b/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx @@ -0,0 +1,273 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { faCheck, faCopy, faKey, faRefresh } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button, Checkbox, IconButton, Slider } from "@app/components/v2"; +import { useTimedReset } from "@app/hooks"; + +type PasswordOptionsType = { + length: number; + useUppercase: boolean; + useLowercase: boolean; + useNumbers: boolean; + useSpecialChars: boolean; +}; + +type PasswordGeneratorModalProps = { + isOpen: boolean; + onClose: () => void; + onUsePassword?: (password: string) => void; + minLength?: number; + maxLength?: number; +}; + +const PasswordGeneratorModal = ({ + isOpen, + onClose, + onUsePassword, + minLength = 12, + maxLength = 64 +}: PasswordGeneratorModalProps) => { + const [copyText, isCopying, setCopyText] = useTimedReset({ + initialState: "Copy" + }); + const [refresh, setRefresh] = useState(false); + const [passwordOptions, setPasswordOptions] = useState({ + length: minLength, + useUppercase: true, + useLowercase: true, + useNumbers: true, + useSpecialChars: true + }); + + const modalRef = useRef(null); + + const generatePassword = () => { + const charset = { + uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + lowercase: "abcdefghijklmnopqrstuvwxyz", + numbers: "0123456789", + specialChars: "-_.~!*" + }; + + let availableChars = ""; + if (passwordOptions.useUppercase) availableChars += charset.uppercase; + if (passwordOptions.useLowercase) availableChars += charset.lowercase; + if (passwordOptions.useNumbers) availableChars += charset.numbers; + if (passwordOptions.useSpecialChars) availableChars += charset.specialChars; + + if (availableChars === "") availableChars = charset.lowercase + charset.numbers; + + let newPassword = ""; + for (let i = 0; i < passwordOptions.length; i += 1) { + const randomIndex = Math.floor(Math.random() * availableChars.length); + newPassword += availableChars[randomIndex]; + } + + return newPassword; + }; + + useEffect(() => { + if (isOpen) { + const handleClickOutside = (event: MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + } + return () => {}; + }, [isOpen, onClose]); + + const password = useMemo(() => { + return generatePassword(); + }, [passwordOptions, refresh]); + + const copyToClipboard = () => { + navigator.clipboard + .writeText(password) + .then(() => { + setCopyText("Copied"); + }) + .catch(() => { + setCopyText("Copy failed"); + }); + }; + + const usePassword = () => { + if (onUsePassword) { + onUsePassword(password); + onClose(); + } + }; + + if (!isOpen) return null; + + return ( +
+
+
+

Password Generator

+

Generate strong unique passwords

+ +
+
+
{password}
+
+ + + +
+
+
+ +
+
+ +
+ setPasswordOptions({ ...passwordOptions, length: value })} + className="mb-1" + aria-labelledby="password-length-label" + /> +
+ +
+ + setPasswordOptions({ ...passwordOptions, useUppercase: checked as boolean }) + } + > + A-Z + + + + setPasswordOptions({ ...passwordOptions, useLowercase: checked as boolean }) + } + > + a-z + + + + setPasswordOptions({ ...passwordOptions, useNumbers: checked as boolean }) + } + > + 0-9 + + + + setPasswordOptions({ ...passwordOptions, useSpecialChars: checked as boolean }) + } + > + -_.~!* + +
+ +
+ + {onUsePassword && ( + + )} +
+
+
+
+ ); +}; + +export type PasswordGeneratorProps = { + onUsePassword?: (password: string) => void; + isDisabled?: boolean; + minLength?: number; + maxLength?: number; +}; + +export const PasswordGenerator = ({ + onUsePassword, + isDisabled = false, + minLength = 12, + maxLength = 64 +}: PasswordGeneratorProps) => { + const [showGenerator, setShowGenerator] = useState(false); + + const toggleGenerator = () => { + setShowGenerator(!showGenerator); + }; + + return ( + <> + + + + + setShowGenerator(false)} + onUsePassword={onUsePassword} + minLength={minLength} + maxLength={maxLength} + /> + + ); +}; diff --git a/frontend/src/components/v2/PasswordGenerator/index.tsx b/frontend/src/components/v2/PasswordGenerator/index.tsx new file mode 100644 index 0000000000..058a80158e --- /dev/null +++ b/frontend/src/components/v2/PasswordGenerator/index.tsx @@ -0,0 +1,2 @@ +export type { PasswordGeneratorProps } from "./PasswordGenerator"; +export { PasswordGenerator } from "./PasswordGenerator"; diff --git a/frontend/src/components/v2/Slider/Slider.tsx b/frontend/src/components/v2/Slider/Slider.tsx new file mode 100644 index 0000000000..cebf67dc27 --- /dev/null +++ b/frontend/src/components/v2/Slider/Slider.tsx @@ -0,0 +1,237 @@ +import { + forwardRef, + InputHTMLAttributes, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState +} from "react"; +import { cva, VariantProps } from "cva"; +import { twMerge } from "tailwind-merge"; + +type Props = { + min: number; + max: number; + step?: number; + isDisabled?: boolean; + isRequired?: boolean; + showValue?: boolean; + valuePosition?: "top" | "right"; + containerClassName?: string; + trackClassName?: string; + fillClassName?: string; + thumbClassName?: string; + onChange?: (value: number) => void; + onChangeComplete?: (value: number) => void; +}; + +const sliderTrackVariants = cva("h-1 w-full bg-mineshaft-600 rounded-full relative", { + variants: { + variant: { + default: "", + thin: "h-0.5", + thick: "h-1.5" + }, + isDisabled: { + true: "opacity-50 cursor-not-allowed", + false: "" + } + } +}); + +const sliderFillVariants = cva("absolute h-full rounded-full", { + variants: { + variant: { + default: "bg-primary-500", + secondary: "bg-secondary-500", + danger: "bg-red-500" + }, + isDisabled: { + true: "opacity-50", + false: "" + } + } +}); + +const sliderThumbVariants = cva( + "absolute w-4 h-4 rounded-full shadow transform -translate-x-1/2 -mt-1.5 focus:outline-none", + { + variants: { + variant: { + default: "bg-primary-500 focus:ring-2 focus:ring-primary-400/50", + secondary: "bg-secondary-500 focus:ring-2 focus:ring-secondary-400/50", + danger: "bg-red-500 focus:ring-2 focus:ring-red-400/50" + }, + isDisabled: { + true: "opacity-50 cursor-not-allowed", + false: "cursor-pointer" + }, + size: { + sm: "w-3 h-3 -mt-1", + md: "w-4 h-4 -mt-1.5", + lg: "w-5 h-5 -mt-2" + } + } + } +); + +const sliderContainerVariants = cva("relative inline-flex font-inter", { + variants: { + isFullWidth: { + true: "w-full", + false: "" + } + } +}); + +export type SliderProps = Omit, "size" | "onChange"> & + VariantProps & + VariantProps & + VariantProps & + Props; + +export const Slider = forwardRef( + ( + { + className, + containerClassName, + trackClassName, + fillClassName, + thumbClassName, + min = 0, + max = 100, + step = 1, + value, + defaultValue, + isDisabled = false, + isFullWidth = true, + isRequired = false, + showValue = false, + valuePosition = "top", + variant = "default", + size = "md", + onChange, + onChangeComplete, + ...props + }, + ref + ): JSX.Element => { + let initialValue = min; + if (value !== undefined) { + initialValue = Number(value); + } else if (defaultValue !== undefined) { + initialValue = Number(defaultValue); + } + + const [currentValue, setCurrentValue] = useState(initialValue); + const [isDragging, setIsDragging] = useState(false); + const inputRef = useRef(null); + const percentage = Math.max(0, Math.min(100, ((currentValue - min) / (max - min)) * 100)); + + useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, []); + + useEffect(() => { + if (value !== undefined && Number(value) !== currentValue) { + setCurrentValue(Number(value)); + } + }, [value, currentValue]); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = Number(e.target.value); + setCurrentValue(newValue); + onChange?.(newValue); + }, + [onChange] + ); + + const handleMouseDown = useCallback(() => { + if (!isDisabled) { + setIsDragging(true); + } + }, [isDisabled]); + + const handleChangeComplete = useCallback(() => { + if (isDragging) { + onChangeComplete?.(currentValue); + setIsDragging(false); + } + }, [isDragging, currentValue, onChangeComplete]); + + useEffect(() => { + if (isDragging) { + const handleGlobalMouseUp = () => handleChangeComplete(); + + document.addEventListener("mouseup", handleGlobalMouseUp); + document.addEventListener("touchend", handleGlobalMouseUp); + + return () => { + document.removeEventListener("mouseup", handleGlobalMouseUp); + document.removeEventListener("touchend", handleGlobalMouseUp); + }; + } + return () => {}; + }, [isDragging, handleChangeComplete]); + + const ValueDisplay = showValue ? ( +
{currentValue}
+ ) : null; + + return ( +
+ {showValue && valuePosition === "top" && ValueDisplay} + +
+
+
+ +
+
+ + + + {showValue && valuePosition === "right" && ( +
{currentValue}
+ )} +
+
+ ); + } +); + +Slider.displayName = "Slider"; diff --git a/frontend/src/components/v2/Slider/index.tsx b/frontend/src/components/v2/Slider/index.tsx new file mode 100644 index 0000000000..2bd94b5587 --- /dev/null +++ b/frontend/src/components/v2/Slider/index.tsx @@ -0,0 +1,2 @@ +export type { SliderProps } from "./Slider"; +export { Slider } from "./Slider"; diff --git a/frontend/src/components/v2/index.tsx b/frontend/src/components/v2/index.tsx index 4c6ffd2fd9..e74380c71a 100644 --- a/frontend/src/components/v2/index.tsx +++ b/frontend/src/components/v2/index.tsx @@ -24,10 +24,12 @@ export * from "./Modal"; export * from "./NoticeBanner"; export * from "./PageHeader"; export * from "./Pagination"; +export * from "./PasswordGenerator"; export * from "./Popoverv2"; export * from "./SecretInput"; export * from "./Select"; export * from "./Skeleton"; +export * from "./Slider"; export * from "./Spinner"; export * from "./Stepper"; export * from "./Switch"; diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx index 7248bc19f5..7087faf72d 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -7,7 +7,13 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2"; +import { + Button, + FilterableSelect, + FormControl, + Input, + PasswordGenerator +} from "@app/components/v2"; import { CreatableSelect } from "@app/components/v2/CreatableSelect"; import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { @@ -226,10 +232,13 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => { isError={Boolean(errors?.value)} errorText={errors?.value?.message} > - +
+ + +
)} /> diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CreateSecretForm/CreateSecretForm.tsx index dea2f8a5ea..a4318d53a7 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -6,7 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, Input } from "@app/components/v2"; +import { Button, FormControl, Input, PasswordGenerator } 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"; @@ -162,12 +162,15 @@ export const CreateSecretForm = ({ isError={Boolean(errors?.value)} errorText={errors?.value?.message} > - +
+ + +
)} />