mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge pull request #3352 from Infisical/feat/addPasswordGenerator
Add new PasswordGenerator and Slider components
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
2
frontend/src/components/v2/PasswordGenerator/index.tsx
Normal file
2
frontend/src/components/v2/PasswordGenerator/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { PasswordGeneratorProps } from "./PasswordGenerator";
|
||||
export { PasswordGenerator } from "./PasswordGenerator";
|
||||
237
frontend/src/components/v2/Slider/Slider.tsx
Normal file
237
frontend/src/components/v2/Slider/Slider.tsx
Normal 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";
|
||||
2
frontend/src/components/v2/Slider/index.tsx
Normal file
2
frontend/src/components/v2/Slider/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { SliderProps } from "./Slider";
|
||||
export { Slider } from "./Slider";
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user