Merge pull request #3352 from Infisical/feat/addPasswordGenerator

Add new PasswordGenerator and Slider components
This commit is contained in:
carlosmonastyrski
2025-04-03 13:01:45 -03:00
committed by GitHub
7 changed files with 540 additions and 12 deletions

View File

@@ -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<string>({
initialState: "Copy"
});
const [refresh, setRefresh] = useState(false);
const [passwordOptions, setPasswordOptions] = useState<PasswordOptionsType>({
length: minLength,
useUppercase: true,
useLowercase: true,
useNumbers: true,
useSpecialChars: true
});
const modalRef = useRef<HTMLDivElement>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
<div
ref={modalRef}
className="w-full max-w-lg rounded-lg border border-mineshaft-600 bg-mineshaft-800 shadow-xl"
>
<div className="p-6">
<h2 className="mb-1 text-xl font-semibold text-bunker-200">Password Generator</h2>
<p className="mb-6 text-sm text-bunker-400">Generate strong unique passwords</p>
<div className="relative mb-4 rounded-md bg-mineshaft-900 p-4">
<div className="flex items-center justify-between">
<div className="w-4/5 select-all break-all pr-2 font-mono text-lg">{password}</div>
<div className="flex flex-col gap-1">
<Button
size="xs"
colorSchema="secondary"
variant="outline_bg"
onClick={() => setRefresh((prev) => !prev)}
className="w-full text-bunker-300 hover:text-bunker-100"
>
<FontAwesomeIcon icon={faRefresh} className="mr-1 h-3 w-3" />
Refresh
</Button>
<Button
size="xs"
colorSchema="secondary"
variant="outline_bg"
onClick={copyToClipboard}
className="w-full text-bunker-300 hover:text-bunker-100"
>
<FontAwesomeIcon icon={isCopying ? faCheck : faCopy} className="mr-1 h-3 w-3" />
{copyText}
</Button>
</div>
</div>
</div>
<div className="mb-6">
<div className="mb-1 flex items-center justify-between">
<label htmlFor="password-length" className="text-sm text-bunker-300">
Password length: {passwordOptions.length}
</label>
</div>
<Slider
id="password-length"
min={minLength}
max={maxLength}
value={passwordOptions.length}
onChange={(value) => setPasswordOptions({ ...passwordOptions, length: value })}
className="mb-1"
aria-labelledby="password-length-label"
/>
</div>
<div className="mb-6 flex flex-row justify-between gap-2">
<Checkbox
id="useUppercase"
className="mr-2 data-[state=checked]:bg-primary"
isChecked={passwordOptions.useUppercase}
onCheckedChange={(checked) =>
setPasswordOptions({ ...passwordOptions, useUppercase: checked as boolean })
}
>
A-Z
</Checkbox>
<Checkbox
id="useLowercase"
className="mr-2 data-[state=checked]:bg-primary"
isChecked={passwordOptions.useLowercase}
onCheckedChange={(checked) =>
setPasswordOptions({ ...passwordOptions, useLowercase: checked as boolean })
}
>
a-z
</Checkbox>
<Checkbox
id="useNumbers"
className="mr-2 data-[state=checked]:bg-primary"
isChecked={passwordOptions.useNumbers}
onCheckedChange={(checked) =>
setPasswordOptions({ ...passwordOptions, useNumbers: checked as boolean })
}
>
0-9
</Checkbox>
<Checkbox
id="useSpecialChars"
className="mr-2 data-[state=checked]:bg-primary"
isChecked={passwordOptions.useSpecialChars}
onCheckedChange={(checked) =>
setPasswordOptions({ ...passwordOptions, useSpecialChars: checked as boolean })
}
>
-_.~!*
</Checkbox>
</div>
<div className="flex justify-end">
<Button size="sm" colorSchema="primary" variant="outline_bg" onClick={onClose}>
Close
</Button>
{onUsePassword && (
<Button
size="sm"
colorSchema="primary"
variant="outline_bg"
onClick={usePassword}
className="ml-2"
>
Use Password
</Button>
)}
</div>
</div>
</div>
</div>
);
};
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 (
<>
<IconButton
ariaLabel="generate password"
colorSchema="secondary"
size="sm"
onClick={toggleGenerator}
isDisabled={isDisabled}
className="rounded text-bunker-400 transition-colors duration-150 hover:bg-mineshaft-700 hover:text-bunker-200"
>
<FontAwesomeIcon icon={faKey} />
</IconButton>
<PasswordGeneratorModal
isOpen={showGenerator}
onClose={() => setShowGenerator(false)}
onUsePassword={onUsePassword}
minLength={minLength}
maxLength={maxLength}
/>
</>
);
};

View File

@@ -0,0 +1,2 @@
export type { PasswordGeneratorProps } from "./PasswordGenerator";
export { PasswordGenerator } from "./PasswordGenerator";

View File

@@ -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<InputHTMLAttributes<HTMLInputElement>, "size" | "onChange"> &
VariantProps<typeof sliderTrackVariants> &
VariantProps<typeof sliderThumbVariants> &
VariantProps<typeof sliderContainerVariants> &
Props;
export const Slider = forwardRef<HTMLInputElement, SliderProps>(
(
{
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<number>(initialValue);
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 ? (
<div className="text-xs text-bunker-300">{currentValue}</div>
) : null;
return (
<div
className={twMerge(
sliderContainerVariants({ isFullWidth, className: containerClassName }),
"my-2"
)}
>
{showValue && valuePosition === "top" && ValueDisplay}
<div className="relative flex w-full items-center">
<div
className={twMerge(
sliderTrackVariants({ variant, isDisabled, className: trackClassName })
)}
>
<div
className={twMerge(
sliderFillVariants({ variant, isDisabled, className: fillClassName }),
"left-0"
)}
style={{ width: `${percentage}%` }}
/>
<div
className={twMerge(
sliderThumbVariants({ variant, isDisabled, size, className: thumbClassName })
)}
style={{ left: `${percentage}%` }}
/>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={currentValue}
onChange={handleChange}
onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown}
disabled={isDisabled}
required={isRequired}
ref={inputRef}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
{...props}
/>
{showValue && valuePosition === "right" && (
<div className="ml-2 text-xs text-bunker-300">{currentValue}</div>
)}
</div>
</div>
);
}
);
Slider.displayName = "Slider";

View File

@@ -0,0 +1,2 @@
export type { SliderProps } from "./Slider";
export { Slider } from "./Slider";

View File

@@ -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";

View File

@@ -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}
>
<InfisicalSecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
<div className="flex items-center gap-2">
<InfisicalSecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
<PasswordGenerator onUsePassword={field.onChange} />
</div>
</FormControl>
)}
/>

View File

@@ -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}
>
<InfisicalSecretInput
{...field}
environment={environment}
secretPath={secretPath}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
<div className="flex items-center gap-2">
<InfisicalSecretInput
{...field}
environment={environment}
secretPath={secretPath}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
<PasswordGenerator onUsePassword={field.onChange} />
</div>
</FormControl>
)}
/>