Miscellaneous fixes (0.17 beta) (#21867)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* Adjust title prompt to have less rigidity

* Improve motion boxes handling for features that don't require motion

* Improve handling of classes starting with digits

* Improve vehicle nuance

* tweak lpr docs

* Improve grammar

* Don't allow # in face name

* add password requirements to new user dialog

* change password requirements

* Clenaup

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
This commit is contained in:
Nicolas Mowen 2026-02-03 07:31:00 -07:00 committed by GitHub
parent e4fe021279
commit 2d83992284
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 256 additions and 145 deletions

View File

@ -29,6 +29,10 @@ auth:
reset_admin_password: true
```
## Password guidance
Constructing secure passwords and managing them properly is important. Frigate requires a minimum length of 12 characters. For guidance on password standards see [NIST SP 800-63B](https://pages.nist.gov/800-63-3/sp800-63b.html). To learn what makes a password truly secure, read this [article](https://medium.com/peerio/how-to-build-a-billion-dollar-password-3d92568d9277).
## Login failure rate limiting
In order to limit the risk of brute force attacks, rate limiting is available for login failures. This is implemented with SlowApi, and the string notation for valid values is available in [the documentation](https://limits.readthedocs.io/en/stable/quickstart.html#examples).

View File

@ -381,6 +381,7 @@ Start with ["Why isn't my license plate being detected and recognized?"](#why-is
```yaml
lpr:
enabled: true
device: CPU
debug_save_plates: true
```

View File

@ -350,21 +350,15 @@ def validate_password_strength(password: str) -> tuple[bool, Optional[str]]:
Validate password strength.
Returns a tuple of (is_valid, error_message).
Longer passwords are harder to crack than shorter complex ones.
https://pages.nist.gov/800-63-3/sp800-63b.html
"""
if not password:
return False, "Password cannot be empty"
if len(password) < 8:
return False, "Password must be at least 8 characters long"
if not any(c.isupper() for c in password):
return False, "Password must contain at least one uppercase letter"
if not any(c.isdigit() for c in password):
return False, "Password must contain at least one digit"
if not any(c in '!@#$%^&*(),.?":{}|<>' for c in password):
return False, "Password must contain at least one special character"
if len(password) < 12:
return False, "Password must be at least 12 characters long"
return True, None
@ -800,7 +794,7 @@ def get_users():
"/users",
dependencies=[Depends(require_role(["admin"]))],
summary="Create new user",
description='Creates a new user with the specified username, password, and role. Requires admin role. Password must meet strength requirements: minimum 8 characters, at least one uppercase letter, at least one digit, and at least one special character (!@#$%^&*(),.?":{} |<>).',
description="Creates a new user with the specified username, password, and role. Requires admin role. Password must be at least 12 characters long.",
)
def create_user(
request: Request,
@ -817,6 +811,15 @@ def create_user(
content={"message": f"Role must be one of: {', '.join(config_roles)}"},
status_code=400,
)
# Validate password strength
is_valid, error_message = validate_password_strength(body.password)
if not is_valid:
return JSONResponse(
content={"message": error_message},
status_code=400,
)
role = body.role or "viewer"
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
User.insert(
@ -851,7 +854,7 @@ def delete_user(request: Request, username: str):
"/users/{username}/password",
dependencies=[Depends(allow_any_authenticated())],
summary="Update user password",
description="Updates a user's password. Users can only change their own password unless they have admin role. Requires the current password to verify identity for non-admin users. Password must meet strength requirements: minimum 8 characters, at least one uppercase letter, at least one digit, and at least one special character (!@#$%^&*(),.?\":{} |<>). If user changes their own password, a new JWT cookie is automatically issued.",
description="Updates a user's password. Users can only change their own password unless they have admin role. Requires the current password to verify identity for non-admin users. Password must be at least 12 characters long. If user changes their own password, a new JWT cookie is automatically issued.",
)
async def update_password(
request: Request,

View File

@ -108,12 +108,13 @@ class GenAIReviewConfig(FrigateBaseModel):
default="""### Normal Activity Indicators (Level 0)
- Known/verified people in any zone at any time
- People with pets in residential areas
- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items normal commute and travel patterns
- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving
- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime
- Activity confined to public areas only (sidewalks, streets) without entering property at any time
### Suspicious Activity Indicators (Level 1)
- **Testing or attempting to open doors/windows/handles on vehicles or buildings** ALWAYS Level 1 regardless of time or duration
- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools Level 1
- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** ALWAYS Level 1 regardless of activity or duration
- Taking items that don't belong to them (packages, objects from porches/driveways)
- Climbing or jumping fences/barriers to access property
@ -133,8 +134,8 @@ Evaluate in this order:
1. **If person is verified/known** Level 0 regardless of time or activity
2. **If person is unidentified:**
- Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) Level 1
- Check actions: If testing doors/handles, taking items, climbing Level 1
- Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service worker) Level 0
- Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing Level 1
- Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) Level 0
3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)
The mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is.""",

View File

@ -97,7 +97,7 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
self.interpreter.allocate_tensors()
self.tensor_input_details = self.interpreter.get_input_details()
self.tensor_output_details = self.interpreter.get_output_details()
self.labelmap = load_labels(labelmap_path, prefill=0)
self.labelmap = load_labels(labelmap_path, prefill=0, indexed=False)
self.classifications_per_second.start()
def __update_metrics(self, duration: float) -> None:
@ -398,7 +398,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
self.interpreter.allocate_tensors()
self.tensor_input_details = self.interpreter.get_input_details()
self.tensor_output_details = self.interpreter.get_output_details()
self.labelmap = load_labels(labelmap_path, prefill=0)
self.labelmap = load_labels(labelmap_path, prefill=0, indexed=False)
def __update_metrics(self, duration: float) -> None:
self.classifications_per_second.update()

View File

@ -633,7 +633,7 @@ class EmbeddingMaintainer(threading.Thread):
camera, frame_name, _, _, motion_boxes, _ = data
if not camera or len(motion_boxes) == 0 or camera not in self.config.cameras:
if not camera or camera not in self.config.cameras:
return
camera_config = self.config.cameras[camera]
@ -660,8 +660,10 @@ class EmbeddingMaintainer(threading.Thread):
return
for processor in self.realtime_processors:
if dedicated_lpr_enabled and isinstance(
processor, LicensePlateRealTimeProcessor
if (
dedicated_lpr_enabled
and len(motion_boxes) > 0
and isinstance(processor, LicensePlateRealTimeProcessor)
):
processor.process_frame(camera, yuv_frame, True)

View File

@ -99,8 +99,8 @@ When forming your description:
## Response Format
Your response MUST be a flat JSON object with:
- `title` (string): A concise, direct title that describes the primary action or event in the sequence, not just what you literally see. Use spatial context when available to make titles more meaningful. When multiple objects/actions are present, prioritize whichever is most prominent or occurs first. Use names from "Objects in Scene" based on what you visually observe. If you see both a name and an unidentified object of the same type but visually observe only one person/object, use ONLY the name. Examples: "Joe walking dog", "Person taking out trash", "Vehicle arriving in driveway", "Joe accessing vehicle", "Person leaving porch for driveway".
- `scene` (string): A narrative description of what happens across the sequence from start to finish, in chronological order. Start by describing how the sequence begins, then describe the progression of events. **Describe all significant movements and actions in the order they occur.** For example, if a vehicle arrives and then a person exits, describe both actions sequentially. **Only describe actions you can actually observe happening in the frames provided.** Do not infer or assume actions that aren't visible (e.g., if you see someone walking but never see them sit, don't say they sat down). Include setting, detected objects, and their observable actions. Avoid speculation or filling in assumed behaviors. Your description should align with and support the threat level you assign.
- `title` (string): A concise, grammatically complete title in the format "[Subject] [action verb] [context]" that matches your scene description. Use names from "Objects in Scene" when you visually observe them.
- `shortSummary` (string): A brief 2-sentence summary of the scene, suitable for notifications. Should capture the key activity and context without full detail. This should be a condensed version of the scene description above.
- `confidence` (float): 0-1 confidence in your analysis. Higher confidence when objects/actions are clearly visible and context is unambiguous. Lower confidence when the sequence is unclear, objects are partially obscured, or context is ambiguous.
- `potential_threat_level` (integer): 0, 1, or 2 as defined in "Normal Activity Patterns for This Property" above. Your threat level must be consistent with your scene description and the guidance above.

View File

@ -129,7 +129,9 @@ def get_ffmpeg_arg_list(arg: Any) -> list:
return arg if isinstance(arg, list) else shlex.split(arg)
def load_labels(path: Optional[str], encoding="utf-8", prefill=91):
def load_labels(
path: Optional[str], encoding="utf-8", prefill=91, indexed: bool | None = None
):
"""Loads labels from file (with or without index numbers).
Args:
path: path to label file.
@ -146,11 +148,12 @@ def load_labels(path: Optional[str], encoding="utf-8", prefill=91):
if not lines:
return {}
if lines[0].split(" ", maxsplit=1)[0].isdigit():
if indexed != False and lines[0].split(" ", maxsplit=1)[0].isdigit():
pairs = [line.split(" ", maxsplit=1) for line in lines]
labels.update({int(index): label.strip() for index, label in pairs})
else:
labels.update({index: line.strip() for index, line in enumerate(lines)})
return labels

View File

@ -2,7 +2,8 @@
"description": {
"addFace": "Add a new collection to the Face Library by uploading your first image.",
"placeholder": "Enter a name for this collection",
"invalidName": "Invalid name. Names can only include letters, numbers, spaces, apostrophes, underscores, and hyphens."
"invalidName": "Invalid name. Names can only include letters, numbers, spaces, apostrophes, underscores, and hyphens.",
"nameCannotContainHash": "Name cannot contain #."
},
"details": {
"timestamp": "Timestamp",

View File

@ -728,10 +728,7 @@
},
"requirements": {
"title": "Password requirements:",
"length": "At least 8 characters",
"uppercase": "At least one uppercase letter",
"digit": "At least one digit",
"special": "At least one special character (!@#$%^&*(),.?\":{}|<>)"
"length": "At least 12 characters"
},
"match": "Passwords match",
"notMatch": "Passwords don't match"

View File

@ -20,6 +20,8 @@ type TextEntryProps = {
children?: React.ReactNode;
regexPattern?: RegExp;
regexErrorMessage?: string;
forbiddenPattern?: RegExp;
forbiddenErrorMessage?: string;
};
export default function TextEntry({
@ -30,11 +32,16 @@ export default function TextEntry({
children,
regexPattern,
regexErrorMessage = "Input does not match the required format",
forbiddenPattern,
forbiddenErrorMessage = "Input contains invalid characters",
}: TextEntryProps) {
const formSchema = z.object({
text: z
.string()
.optional()
.refine((val) => !val || !forbiddenPattern?.test(val), {
message: forbiddenErrorMessage,
})
.refine(
(val) => {
if (!allowEmpty && !val) return false;

View File

@ -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,9 @@ 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(12, t("users.dialog.form.password.requirements.length")),
confirmPassword: z
.string()
.min(1, t("users.dialog.createUser.confirmPassword")),
@ -108,13 +120,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 +148,11 @@ export default function CreateUserDialog({
form.reset({
user: "",
password: "",
confirmPassword: "",
role: "viewer",
});
setShowPasswordVisible(false);
setShowConfirmPassword(false);
onCancel();
};
@ -184,13 +213,88 @@ 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 * 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>
</ul>
</div>
</div>
)}
<FormMessage />
</FormItem>
)}
@ -204,14 +308,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">

View File

@ -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;
@ -70,13 +76,7 @@ export default function SetPasswordDialog({
const baseSchema = {
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"),
),
.min(12, t("users.dialog.form.password.requirements.length")),
confirmPassword: z.string(),
};
@ -125,25 +125,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 +184,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 +336,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`}
style={{ width: `${(passwordStrength / 4) * 100}%` }}
className={`${getPasswordStrengthColor(
password,
)} transition-all duration-300`}
style={{ width: `${passwordStrength * 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>
@ -401,60 +372,6 @@ export default function SetPasswordDialog({
)}
</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>

View File

@ -128,6 +128,8 @@ export default function CreateFaceWizardDialog({
}}
regexPattern={/^[\p{L}\p{N}\s'_-]{1,50}$/u}
regexErrorMessage={t("description.invalidName")}
forbiddenPattern={/#/}
forbiddenErrorMessage={t("description.nameCannotContainHash")}
>
<div className="flex justify-end py-2">
<Button variant="select" type="submit">

View File

@ -22,6 +22,8 @@ type TextEntryDialogProps = {
allowEmpty?: boolean;
regexPattern?: RegExp;
regexErrorMessage?: string;
forbiddenPattern?: RegExp;
forbiddenErrorMessage?: string;
};
export default function TextEntryDialog({
@ -34,6 +36,8 @@ export default function TextEntryDialog({
allowEmpty = false,
regexPattern,
regexErrorMessage,
forbiddenPattern,
forbiddenErrorMessage,
}: TextEntryDialogProps) {
const { t } = useTranslation("common");
@ -50,6 +54,8 @@ export default function TextEntryDialog({
onSave={onSave}
regexPattern={regexPattern}
regexErrorMessage={regexErrorMessage}
forbiddenPattern={forbiddenPattern}
forbiddenErrorMessage={forbiddenErrorMessage}
>
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
<Button type="button" onClick={() => setOpen(false)}>

View File

@ -560,6 +560,8 @@ function LibrarySelector({
defaultValue={renameFace || ""}
regexPattern={/^[\p{L}\p{N}\s'_-]{1,50}$/u}
regexErrorMessage={t("description.invalidName")}
forbiddenPattern={/#/}
forbiddenErrorMessage={t("description.nameCannotContainHash")}
/>
<DropdownMenu modal={false}>

View File

@ -0,0 +1,34 @@
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 >= 12,
});
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");
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 === 0) return "bg-red-500";
return "bg-green-500";
};