mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-21 23:58:22 +03:00
add password requirements to new user dialog
This commit is contained in:
parent
ac6dda370d
commit
8b426743c2
@ -32,11 +32,17 @@ import {
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Shield, User } from "lucide-react";
|
||||
import { LuCheck, LuX } from "react-icons/lu";
|
||||
import { LuCheck, LuX, LuEye, LuEyeOff } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import {
|
||||
calculatePasswordStrength,
|
||||
getPasswordRequirements,
|
||||
getPasswordStrengthLabel,
|
||||
getPasswordStrengthColor,
|
||||
} from "@/utils/passwordUtil";
|
||||
import {
|
||||
MobilePage,
|
||||
MobilePageContent,
|
||||
@ -59,6 +65,10 @@ export default function CreateUserDialog({
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [showPasswordVisible, setShowPasswordVisible] =
|
||||
useState<boolean>(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const roles = useMemo(() => {
|
||||
const existingRoles = config ? Object.keys(config.auth?.roles || {}) : [];
|
||||
@ -73,7 +83,15 @@ export default function CreateUserDialog({
|
||||
.regex(/^[A-Za-z0-9._]+$/, {
|
||||
message: t("users.dialog.createUser.usernameOnlyInclude"),
|
||||
}),
|
||||
password: z.string().min(1, t("users.dialog.form.passwordIsRequired")),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, t("users.dialog.form.password.requirements.length"))
|
||||
.regex(/[A-Z]/, t("users.dialog.form.password.requirements.uppercase"))
|
||||
.regex(/\d/, t("users.dialog.form.password.requirements.digit"))
|
||||
.regex(
|
||||
/[!@#$%^&*(),.?":{}|<>]/,
|
||||
t("users.dialog.form.password.requirements.special"),
|
||||
),
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.min(1, t("users.dialog.createUser.confirmPassword")),
|
||||
@ -108,13 +126,27 @@ export default function CreateUserDialog({
|
||||
const passwordsMatch = password === confirmPassword;
|
||||
const showMatchIndicator = password && confirmPassword;
|
||||
|
||||
// Password strength calculation
|
||||
const passwordStrength = useMemo(
|
||||
() => calculatePasswordStrength(password),
|
||||
[password],
|
||||
);
|
||||
|
||||
const requirements = useMemo(
|
||||
() => getPasswordRequirements(password),
|
||||
[password],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
form.reset({
|
||||
user: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
role: "viewer",
|
||||
});
|
||||
setShowPasswordVisible(false);
|
||||
setShowConfirmPassword(false);
|
||||
}
|
||||
}, [show, form]);
|
||||
|
||||
@ -122,8 +154,11 @@ export default function CreateUserDialog({
|
||||
form.reset({
|
||||
user: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
role: "viewer",
|
||||
});
|
||||
setShowPasswordVisible(false);
|
||||
setShowConfirmPassword(false);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
@ -184,13 +219,142 @@ export default function CreateUserDialog({
|
||||
{t("users.dialog.form.password.title")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("users.dialog.form.password.placeholder")}
|
||||
type="password"
|
||||
className="h-10"
|
||||
{...field}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t(
|
||||
"users.dialog.form.password.placeholder",
|
||||
)}
|
||||
type={showPasswordVisible ? "text" : "password"}
|
||||
className="h-10 pr-10"
|
||||
{...field}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
aria-label={
|
||||
showPasswordVisible
|
||||
? t("users.dialog.form.password.hide", {
|
||||
ns: "views/settings",
|
||||
})
|
||||
: t("users.dialog.form.password.show", {
|
||||
ns: "views/settings",
|
||||
})
|
||||
}
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() =>
|
||||
setShowPasswordVisible(!showPasswordVisible)
|
||||
}
|
||||
>
|
||||
{showPasswordVisible ? (
|
||||
<LuEyeOff className="size-4" />
|
||||
) : (
|
||||
<LuEye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
{password && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-secondary-foreground">
|
||||
<div
|
||||
className={`${getPasswordStrengthColor(
|
||||
password,
|
||||
)} transition-all duration-300`}
|
||||
style={{ width: `${(passwordStrength / 4) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("users.dialog.form.password.strength.title")}
|
||||
<span className="font-medium">
|
||||
{getPasswordStrengthLabel(password, t)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="space-y-1 rounded-md bg-muted/50 p-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t("users.dialog.form.password.requirements.title")}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
<li className="flex items-center gap-2 text-xs">
|
||||
{requirements.length ? (
|
||||
<LuCheck className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<LuX className="size-3.5 text-red-500" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
requirements.length
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{t(
|
||||
"users.dialog.form.password.requirements.length",
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-xs">
|
||||
{requirements.uppercase ? (
|
||||
<LuCheck className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<LuX className="size-3.5 text-red-500" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
requirements.uppercase
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{t(
|
||||
"users.dialog.form.password.requirements.uppercase",
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-xs">
|
||||
{requirements.digit ? (
|
||||
<LuCheck className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<LuX className="size-3.5 text-red-500" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
requirements.digit
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{t(
|
||||
"users.dialog.form.password.requirements.digit",
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-xs">
|
||||
{requirements.special ? (
|
||||
<LuCheck className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<LuX className="size-3.5 text-red-500" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
requirements.special
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{t(
|
||||
"users.dialog.form.password.requirements.special",
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@ -204,14 +368,41 @@ export default function CreateUserDialog({
|
||||
{t("users.dialog.form.password.confirm.title")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"users.dialog.form.password.confirm.placeholder",
|
||||
)}
|
||||
type="password"
|
||||
className="h-10"
|
||||
{...field}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t(
|
||||
"users.dialog.form.password.confirm.placeholder",
|
||||
)}
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
className="h-10 pr-10"
|
||||
{...field}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
aria-label={
|
||||
showConfirmPassword
|
||||
? t("users.dialog.form.password.hide", {
|
||||
ns: "views/settings",
|
||||
})
|
||||
: t("users.dialog.form.password.show", {
|
||||
ns: "views/settings",
|
||||
})
|
||||
}
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() =>
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<LuEyeOff className="size-4" />
|
||||
) : (
|
||||
<LuEye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
{showMatchIndicator && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs">
|
||||
|
||||
@ -28,6 +28,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import {
|
||||
calculatePasswordStrength,
|
||||
getPasswordRequirements,
|
||||
getPasswordStrengthLabel,
|
||||
getPasswordStrengthColor,
|
||||
} from "@/utils/passwordUtil";
|
||||
|
||||
type SetPasswordProps = {
|
||||
show: boolean;
|
||||
@ -125,25 +131,13 @@ export default function SetPasswordDialog({
|
||||
const confirmPassword = form.watch("confirmPassword");
|
||||
|
||||
// Password strength calculation
|
||||
const passwordStrength = useMemo(() => {
|
||||
if (!password) return 0;
|
||||
|
||||
let strength = 0;
|
||||
if (password.length >= 8) strength += 1;
|
||||
if (/\d/.test(password)) strength += 1;
|
||||
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 1;
|
||||
if (/[A-Z]/.test(password)) strength += 1;
|
||||
|
||||
return strength;
|
||||
}, [password]);
|
||||
const passwordStrength = useMemo(
|
||||
() => calculatePasswordStrength(password),
|
||||
[password],
|
||||
);
|
||||
|
||||
const requirements = useMemo(
|
||||
() => ({
|
||||
length: password?.length >= 8,
|
||||
uppercase: /[A-Z]/.test(password || ""),
|
||||
digit: /\d/.test(password || ""),
|
||||
special: /[!@#$%^&*(),.?":{}|<>]/.test(password || ""),
|
||||
}),
|
||||
() => getPasswordRequirements(password),
|
||||
[password],
|
||||
);
|
||||
|
||||
@ -196,25 +190,6 @@ export default function SetPasswordDialog({
|
||||
onSave(values.password, oldPassword);
|
||||
};
|
||||
|
||||
const getStrengthLabel = () => {
|
||||
if (!password) return "";
|
||||
if (passwordStrength <= 1)
|
||||
return t("users.dialog.form.password.strength.weak");
|
||||
if (passwordStrength === 2)
|
||||
return t("users.dialog.form.password.strength.medium");
|
||||
if (passwordStrength === 3)
|
||||
return t("users.dialog.form.password.strength.strong");
|
||||
return t("users.dialog.form.password.strength.veryStrong");
|
||||
};
|
||||
|
||||
const getStrengthColor = () => {
|
||||
if (!password) return "bg-gray-200";
|
||||
if (passwordStrength <= 1) return "bg-red-500";
|
||||
if (passwordStrength === 2) return "bg-yellow-500";
|
||||
if (passwordStrength === 3) return "bg-green-500";
|
||||
return "bg-green-600";
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={show} onOpenChange={onCancel}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
@ -367,14 +342,16 @@ export default function SetPasswordDialog({
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-secondary-foreground">
|
||||
<div
|
||||
className={`${getStrengthColor()} transition-all duration-300`}
|
||||
className={`${getPasswordStrengthColor(
|
||||
password,
|
||||
)} transition-all duration-300`}
|
||||
style={{ width: `${(passwordStrength / 4) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("users.dialog.form.password.strength.title")}
|
||||
<span className="font-medium">
|
||||
{getStrengthLabel()}
|
||||
{getPasswordStrengthLabel(password, t)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
|
||||
41
web/src/utils/passwordUtil.ts
Normal file
41
web/src/utils/passwordUtil.ts
Normal file
@ -0,0 +1,41 @@
|
||||
export const calculatePasswordStrength = (password: string): number => {
|
||||
if (!password) return 0;
|
||||
|
||||
let strength = 0;
|
||||
if (password.length >= 8) strength += 1;
|
||||
if (/\d/.test(password)) strength += 1;
|
||||
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 1;
|
||||
if (/[A-Z]/.test(password)) strength += 1;
|
||||
|
||||
return strength;
|
||||
};
|
||||
|
||||
export const getPasswordRequirements = (password: string) => ({
|
||||
length: password?.length >= 8,
|
||||
uppercase: /[A-Z]/.test(password || ""),
|
||||
digit: /\d/.test(password || ""),
|
||||
special: /[!@#$%^&*(),.?":{}|<>]/.test(password || ""),
|
||||
});
|
||||
|
||||
export const getPasswordStrengthLabel = (
|
||||
password: string,
|
||||
t: (key: string) => string,
|
||||
): string => {
|
||||
const strength = calculatePasswordStrength(password);
|
||||
|
||||
if (!password) return "";
|
||||
if (strength <= 1) return t("users.dialog.form.password.strength.weak");
|
||||
if (strength === 2) return t("users.dialog.form.password.strength.medium");
|
||||
if (strength === 3) return t("users.dialog.form.password.strength.strong");
|
||||
return t("users.dialog.form.password.strength.veryStrong");
|
||||
};
|
||||
|
||||
export const getPasswordStrengthColor = (password: string): string => {
|
||||
const strength = calculatePasswordStrength(password);
|
||||
|
||||
if (!password) return "bg-gray-200";
|
||||
if (strength <= 1) return "bg-red-500";
|
||||
if (strength === 2) return "bg-yellow-500";
|
||||
if (strength === 3) return "bg-green-500";
|
||||
return "bg-green-600";
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user