From 0088217fa9c072fe08d5d142cfe8d1c24fac7180 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Wed, 2 Apr 2025 11:42:01 -0300 Subject: [PATCH 1/4] Add new PasswordGenerator and Slider components --- .../PasswordGenerator/PasswordGenerator.tsx | 269 ++++++++++++++++++ .../components/v2/PasswordGenerator/index.tsx | 2 + frontend/src/components/v2/Slider/Slider.tsx | 237 +++++++++++++++ frontend/src/components/v2/Slider/index.tsx | 2 + frontend/src/components/v2/index.tsx | 2 + .../components/ShareSecretForm.tsx | 33 ++- .../CreateSecretForm/CreateSecretForm.tsx | 19 +- .../CreateSecretForm/CreateSecretForm.tsx | 17 +- 8 files changed, 558 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx create mode 100644 frontend/src/components/v2/PasswordGenerator/index.tsx create mode 100644 frontend/src/components/v2/Slider/Slider.tsx create mode 100644 frontend/src/components/v2/Slider/index.tsx diff --git a/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx b/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx new file mode 100644 index 000000000..c625c7e80 --- /dev/null +++ b/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx @@ -0,0 +1,269 @@ +import { useEffect, useRef, useState } from "react"; +import { faCopy, faKey, faRefresh } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button, Checkbox, IconButton, Slider } from "@app/components/v2"; + +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 = 6, + maxLength = 32 +}: PasswordGeneratorModalProps) => { + const [password, setPassword] = useState(""); + const [copied, setCopied] = 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]; + } + + setPassword(newPassword); + setCopied(false); + }; + + useEffect(() => { + if (isOpen) { + generatePassword(); + + 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]); + + useEffect(() => { + generatePassword(); + }, [passwordOptions]); + + const copyToClipboard = () => { + navigator.clipboard.writeText(password).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + 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 = 6, + maxLength = 32 +}: 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 000000000..058a80158 --- /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 000000000..cebf67dc2 --- /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 000000000..2bd94b558 --- /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 4c6ffd2fd..e74380c71 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/public/ShareSecretPage/components/ShareSecretForm.tsx b/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx index a2228e46b..3166444e1 100644 --- a/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx +++ b/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx @@ -6,7 +6,15 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2"; +import { + Button, + FormControl, + IconButton, + Input, + PasswordGenerator, + Select, + SelectItem +} from "@app/components/v2"; import { useTimedReset } from "@app/hooks"; import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api"; import { SecretSharingAccessType } from "@app/hooks/api/secretSharing"; @@ -167,16 +175,19 @@ export const ShareSecretForm = ({ errorText={error?.message} isOptional > - +
+ + +
)} /> 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 7248bc19f..7087faf72 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 dea2f8a5e..a4318d53a 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} > - +
+ + +
)} /> From d8313a161e2b63e38bc8c888139e96d6d127aaba Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Wed, 2 Apr 2025 15:27:05 -0300 Subject: [PATCH 2/4] Moved from useEffect to useMemo on PasswordGeneratorModal and remove it from secret-share --- .../PasswordGenerator/PasswordGenerator.tsx | 46 +++++++++++-------- .../components/ShareSecretForm.tsx | 33 +++++-------- 2 files changed, 38 insertions(+), 41 deletions(-) diff --git a/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx b/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx index c625c7e80..79e823ab4 100644 --- a/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx +++ b/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx @@ -1,8 +1,9 @@ -import { useEffect, useRef, useState } from "react"; -import { faCopy, faKey, faRefresh } from "@fortawesome/free-solid-svg-icons"; +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; @@ -24,11 +25,13 @@ const PasswordGeneratorModal = ({ isOpen, onClose, onUsePassword, - minLength = 6, - maxLength = 32 + minLength = 12, + maxLength = 64 }: PasswordGeneratorModalProps) => { - const [password, setPassword] = useState(""); - const [copied, setCopied] = useState(false); + const [copyText, isCopying, setCopyText] = useTimedReset({ + initialState: "Copy" + }); + const [refresh, setRefresh] = useState(false); const [passwordOptions, setPasswordOptions] = useState({ length: minLength, useUppercase: true, @@ -61,8 +64,7 @@ const PasswordGeneratorModal = ({ newPassword += availableChars[randomIndex]; } - setPassword(newPassword); - setCopied(false); + return newPassword; }; useEffect(() => { @@ -81,14 +83,20 @@ const PasswordGeneratorModal = ({ return () => {}; }, [isOpen, onClose]); - useEffect(() => { - generatePassword(); - }, [passwordOptions]); + const password = useMemo(() => { + return generatePassword(); + }, [ + passwordOptions.length, + passwordOptions.useUppercase, + passwordOptions.useLowercase, + passwordOptions.useNumbers, + passwordOptions.useSpecialChars, + refresh + ]); const copyToClipboard = () => { navigator.clipboard.writeText(password).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); + setCopyText("Copied"); }); }; @@ -105,7 +113,7 @@ const PasswordGeneratorModal = ({

Password Generator

@@ -119,7 +127,7 @@ const PasswordGeneratorModal = ({ size="xs" colorSchema="secondary" variant="outline_bg" - onClick={generatePassword} + onClick={() => setRefresh((prev) => !prev)} className="w-full text-bunker-300 hover:text-bunker-100" > @@ -133,8 +141,8 @@ const PasswordGeneratorModal = ({ onClick={copyToClipboard} className="w-full text-bunker-300 hover:text-bunker-100" > - - {copied ? "Copied!" : "Copy"} + + {copyText}
@@ -235,8 +243,8 @@ export type PasswordGeneratorProps = { export const PasswordGenerator = ({ onUsePassword, isDisabled = false, - minLength = 6, - maxLength = 32 + minLength = 12, + maxLength = 64 }: PasswordGeneratorProps) => { const [showGenerator, setShowGenerator] = useState(false); diff --git a/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx b/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx index 3166444e1..a2228e46b 100644 --- a/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx +++ b/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx @@ -6,15 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { - Button, - FormControl, - IconButton, - Input, - PasswordGenerator, - Select, - SelectItem -} from "@app/components/v2"; +import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2"; import { useTimedReset } from "@app/hooks"; import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api"; import { SecretSharingAccessType } from "@app/hooks/api/secretSharing"; @@ -175,19 +167,16 @@ export const ShareSecretForm = ({ errorText={error?.message} isOptional > -
- - -
+ )} /> From ecd39abdc1019dd36e72baa2b4d8fbfa0e47e64f Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Wed, 2 Apr 2025 15:39:31 -0300 Subject: [PATCH 3/4] Remove unnecessary function call --- .../v2/PasswordGenerator/PasswordGenerator.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx b/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx index 79e823ab4..1f17e2e53 100644 --- a/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx +++ b/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx @@ -69,8 +69,6 @@ const PasswordGeneratorModal = ({ useEffect(() => { if (isOpen) { - generatePassword(); - const handleClickOutside = (event: MouseEvent) => { if (modalRef.current && !modalRef.current.contains(event.target as Node)) { onClose(); @@ -95,9 +93,14 @@ const PasswordGeneratorModal = ({ ]); const copyToClipboard = () => { - navigator.clipboard.writeText(password).then(() => { - setCopyText("Copied"); - }); + navigator.clipboard + .writeText(password) + .then(() => { + setCopyText("Copied"); + }) + .catch(() => { + setCopyText("Copy failed"); + }); }; const usePassword = () => { From 93baf9728bc7bccb588a4ba8a081bab61305fb97 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Wed, 2 Apr 2025 17:20:22 -0300 Subject: [PATCH 4/4] Small fix on PasswordGeneratorModal useMemo --- .../v2/PasswordGenerator/PasswordGenerator.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx b/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx index 1f17e2e53..62002b492 100644 --- a/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx +++ b/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx @@ -83,14 +83,7 @@ const PasswordGeneratorModal = ({ const password = useMemo(() => { return generatePassword(); - }, [ - passwordOptions.length, - passwordOptions.useUppercase, - passwordOptions.useLowercase, - passwordOptions.useNumbers, - passwordOptions.useSpecialChars, - refresh - ]); + }, [passwordOptions, refresh]); const copyToClipboard = () => { navigator.clipboard