mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-01 17:11:54 -05:00
Compare commits
9 Commits
dev
...
misc-fixes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8cccaaf1c | ||
|
|
8b426743c2 | ||
|
|
ac6dda370d | ||
|
|
aaa61ae2e0 | ||
|
|
923f78df35 | ||
|
|
1ca3c7d569 | ||
|
|
d9b420929e | ||
|
|
f66c4f53e0 | ||
|
|
bbab93eb96 |
@@ -29,6 +29,10 @@ auth:
|
|||||||
reset_admin_password: true
|
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
|
## 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).
|
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).
|
||||||
|
|||||||
@@ -381,6 +381,7 @@ Start with ["Why isn't my license plate being detected and recognized?"](#why-is
|
|||||||
```yaml
|
```yaml
|
||||||
lpr:
|
lpr:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
device: CPU
|
||||||
debug_save_plates: true
|
debug_save_plates: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -350,21 +350,15 @@ def validate_password_strength(password: str) -> tuple[bool, Optional[str]]:
|
|||||||
Validate password strength.
|
Validate password strength.
|
||||||
|
|
||||||
Returns a tuple of (is_valid, error_message).
|
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:
|
if not password:
|
||||||
return False, "Password cannot be empty"
|
return False, "Password cannot be empty"
|
||||||
|
|
||||||
if len(password) < 8:
|
if len(password) < 12:
|
||||||
return False, "Password must be at least 8 characters long"
|
return False, "Password must be at least 12 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"
|
|
||||||
|
|
||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
@@ -800,7 +794,7 @@ def get_users():
|
|||||||
"/users",
|
"/users",
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
summary="Create new user",
|
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(
|
def create_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -817,6 +811,15 @@ def create_user(
|
|||||||
content={"message": f"Role must be one of: {', '.join(config_roles)}"},
|
content={"message": f"Role must be one of: {', '.join(config_roles)}"},
|
||||||
status_code=400,
|
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"
|
role = body.role or "viewer"
|
||||||
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
|
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
|
||||||
User.insert(
|
User.insert(
|
||||||
@@ -851,7 +854,7 @@ def delete_user(request: Request, username: str):
|
|||||||
"/users/{username}/password",
|
"/users/{username}/password",
|
||||||
dependencies=[Depends(allow_any_authenticated())],
|
dependencies=[Depends(allow_any_authenticated())],
|
||||||
summary="Update user password",
|
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(
|
async def update_password(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -108,12 +108,13 @@ class GenAIReviewConfig(FrigateBaseModel):
|
|||||||
default="""### Normal Activity Indicators (Level 0)
|
default="""### Normal Activity Indicators (Level 0)
|
||||||
- Known/verified people in any zone at any time
|
- Known/verified people in any zone at any time
|
||||||
- People with pets in residential areas
|
- 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
|
- 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
|
- 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
|
- Activity confined to public areas only (sidewalks, streets) without entering property at any time
|
||||||
|
|
||||||
### Suspicious Activity Indicators (Level 1)
|
### 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
|
- **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)
|
- Taking items that don't belong to them (packages, objects from porches/driveways)
|
||||||
- Climbing or jumping fences/barriers to access property
|
- 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
|
1. **If person is verified/known** → Level 0 regardless of time or activity
|
||||||
2. **If person is unidentified:**
|
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 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
|
- 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 worker) → Level 0
|
- 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)
|
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.""",
|
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.""",
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
|||||||
self.interpreter.allocate_tensors()
|
self.interpreter.allocate_tensors()
|
||||||
self.tensor_input_details = self.interpreter.get_input_details()
|
self.tensor_input_details = self.interpreter.get_input_details()
|
||||||
self.tensor_output_details = self.interpreter.get_output_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()
|
self.classifications_per_second.start()
|
||||||
|
|
||||||
def __update_metrics(self, duration: float) -> None:
|
def __update_metrics(self, duration: float) -> None:
|
||||||
@@ -398,7 +398,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
|||||||
self.interpreter.allocate_tensors()
|
self.interpreter.allocate_tensors()
|
||||||
self.tensor_input_details = self.interpreter.get_input_details()
|
self.tensor_input_details = self.interpreter.get_input_details()
|
||||||
self.tensor_output_details = self.interpreter.get_output_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:
|
def __update_metrics(self, duration: float) -> None:
|
||||||
self.classifications_per_second.update()
|
self.classifications_per_second.update()
|
||||||
|
|||||||
@@ -633,7 +633,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
|
|
||||||
camera, frame_name, _, _, motion_boxes, _ = data
|
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
|
return
|
||||||
|
|
||||||
camera_config = self.config.cameras[camera]
|
camera_config = self.config.cameras[camera]
|
||||||
@@ -660,8 +660,10 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
return
|
return
|
||||||
|
|
||||||
for processor in self.realtime_processors:
|
for processor in self.realtime_processors:
|
||||||
if dedicated_lpr_enabled and isinstance(
|
if (
|
||||||
processor, LicensePlateRealTimeProcessor
|
dedicated_lpr_enabled
|
||||||
|
and len(motion_boxes) > 0
|
||||||
|
and isinstance(processor, LicensePlateRealTimeProcessor)
|
||||||
):
|
):
|
||||||
processor.process_frame(camera, yuv_frame, True)
|
processor.process_frame(camera, yuv_frame, True)
|
||||||
|
|
||||||
|
|||||||
@@ -99,8 +99,8 @@ When forming your description:
|
|||||||
## Response Format
|
## Response Format
|
||||||
|
|
||||||
Your response MUST be a flat JSON object with:
|
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.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ def get_ffmpeg_arg_list(arg: Any) -> list:
|
|||||||
return arg if isinstance(arg, list) else shlex.split(arg)
|
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).
|
"""Loads labels from file (with or without index numbers).
|
||||||
Args:
|
Args:
|
||||||
path: path to label file.
|
path: path to label file.
|
||||||
@@ -146,11 +146,12 @@ def load_labels(path: Optional[str], encoding="utf-8", prefill=91):
|
|||||||
if not lines:
|
if not lines:
|
||||||
return {}
|
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]
|
pairs = [line.split(" ", maxsplit=1) for line in lines]
|
||||||
labels.update({int(index): label.strip() for index, label in pairs})
|
labels.update({int(index): label.strip() for index, label in pairs})
|
||||||
else:
|
else:
|
||||||
labels.update({index: line.strip() for index, line in enumerate(lines)})
|
labels.update({index: line.strip() for index, line in enumerate(lines)})
|
||||||
|
|
||||||
return labels
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"addFace": "Add a new collection to the Face Library by uploading your first image.",
|
"addFace": "Add a new collection to the Face Library by uploading your first image.",
|
||||||
"placeholder": "Enter a name for this collection",
|
"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": {
|
"details": {
|
||||||
"timestamp": "Timestamp",
|
"timestamp": "Timestamp",
|
||||||
|
|||||||
@@ -728,10 +728,7 @@
|
|||||||
},
|
},
|
||||||
"requirements": {
|
"requirements": {
|
||||||
"title": "Password requirements:",
|
"title": "Password requirements:",
|
||||||
"length": "At least 8 characters",
|
"length": "At least 12 characters"
|
||||||
"uppercase": "At least one uppercase letter",
|
|
||||||
"digit": "At least one digit",
|
|
||||||
"special": "At least one special character (!@#$%^&*(),.?\":{}|<>)"
|
|
||||||
},
|
},
|
||||||
"match": "Passwords match",
|
"match": "Passwords match",
|
||||||
"notMatch": "Passwords don't match"
|
"notMatch": "Passwords don't match"
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ type TextEntryProps = {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
regexPattern?: RegExp;
|
regexPattern?: RegExp;
|
||||||
regexErrorMessage?: string;
|
regexErrorMessage?: string;
|
||||||
|
forbiddenPattern?: RegExp;
|
||||||
|
forbiddenErrorMessage?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TextEntry({
|
export default function TextEntry({
|
||||||
@@ -30,11 +32,16 @@ export default function TextEntry({
|
|||||||
children,
|
children,
|
||||||
regexPattern,
|
regexPattern,
|
||||||
regexErrorMessage = "Input does not match the required format",
|
regexErrorMessage = "Input does not match the required format",
|
||||||
|
forbiddenPattern,
|
||||||
|
forbiddenErrorMessage = "Input contains invalid characters",
|
||||||
}: TextEntryProps) {
|
}: TextEntryProps) {
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
text: z
|
text: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
|
.refine((val) => !val || !forbiddenPattern?.test(val), {
|
||||||
|
message: forbiddenErrorMessage,
|
||||||
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(val) => {
|
(val) => {
|
||||||
if (!allowEmpty && !val) return false;
|
if (!allowEmpty && !val) return false;
|
||||||
|
|||||||
@@ -32,11 +32,17 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "../ui/select";
|
} from "../ui/select";
|
||||||
import { Shield, User } from "lucide-react";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import {
|
||||||
|
calculatePasswordStrength,
|
||||||
|
getPasswordRequirements,
|
||||||
|
getPasswordStrengthLabel,
|
||||||
|
getPasswordStrengthColor,
|
||||||
|
} from "@/utils/passwordUtil";
|
||||||
import {
|
import {
|
||||||
MobilePage,
|
MobilePage,
|
||||||
MobilePageContent,
|
MobilePageContent,
|
||||||
@@ -59,6 +65,10 @@ export default function CreateUserDialog({
|
|||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation(["views/settings"]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [showPasswordVisible, setShowPasswordVisible] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
const roles = useMemo(() => {
|
const roles = useMemo(() => {
|
||||||
const existingRoles = config ? Object.keys(config.auth?.roles || {}) : [];
|
const existingRoles = config ? Object.keys(config.auth?.roles || {}) : [];
|
||||||
@@ -73,7 +83,9 @@ export default function CreateUserDialog({
|
|||||||
.regex(/^[A-Za-z0-9._]+$/, {
|
.regex(/^[A-Za-z0-9._]+$/, {
|
||||||
message: t("users.dialog.createUser.usernameOnlyInclude"),
|
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
|
confirmPassword: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, t("users.dialog.createUser.confirmPassword")),
|
.min(1, t("users.dialog.createUser.confirmPassword")),
|
||||||
@@ -108,13 +120,27 @@ export default function CreateUserDialog({
|
|||||||
const passwordsMatch = password === confirmPassword;
|
const passwordsMatch = password === confirmPassword;
|
||||||
const showMatchIndicator = password && confirmPassword;
|
const showMatchIndicator = password && confirmPassword;
|
||||||
|
|
||||||
|
// Password strength calculation
|
||||||
|
const passwordStrength = useMemo(
|
||||||
|
() => calculatePasswordStrength(password),
|
||||||
|
[password],
|
||||||
|
);
|
||||||
|
|
||||||
|
const requirements = useMemo(
|
||||||
|
() => getPasswordRequirements(password),
|
||||||
|
[password],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!show) {
|
if (!show) {
|
||||||
form.reset({
|
form.reset({
|
||||||
user: "",
|
user: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
role: "viewer",
|
role: "viewer",
|
||||||
});
|
});
|
||||||
|
setShowPasswordVisible(false);
|
||||||
|
setShowConfirmPassword(false);
|
||||||
}
|
}
|
||||||
}, [show, form]);
|
}, [show, form]);
|
||||||
|
|
||||||
@@ -122,8 +148,11 @@ export default function CreateUserDialog({
|
|||||||
form.reset({
|
form.reset({
|
||||||
user: "",
|
user: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
role: "viewer",
|
role: "viewer",
|
||||||
});
|
});
|
||||||
|
setShowPasswordVisible(false);
|
||||||
|
setShowConfirmPassword(false);
|
||||||
onCancel();
|
onCancel();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,13 +213,88 @@ export default function CreateUserDialog({
|
|||||||
{t("users.dialog.form.password.title")}
|
{t("users.dialog.form.password.title")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<div className="relative">
|
||||||
placeholder={t("users.dialog.form.password.placeholder")}
|
<Input
|
||||||
type="password"
|
placeholder={t(
|
||||||
className="h-10"
|
"users.dialog.form.password.placeholder",
|
||||||
{...field}
|
)}
|
||||||
/>
|
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>
|
</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 />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -204,14 +308,41 @@ export default function CreateUserDialog({
|
|||||||
{t("users.dialog.form.password.confirm.title")}
|
{t("users.dialog.form.password.confirm.title")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<div className="relative">
|
||||||
placeholder={t(
|
<Input
|
||||||
"users.dialog.form.password.confirm.placeholder",
|
placeholder={t(
|
||||||
)}
|
"users.dialog.form.password.confirm.placeholder",
|
||||||
type="password"
|
)}
|
||||||
className="h-10"
|
type={showConfirmPassword ? "text" : "password"}
|
||||||
{...field}
|
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>
|
</FormControl>
|
||||||
{showMatchIndicator && (
|
{showMatchIndicator && (
|
||||||
<div className="mt-1 flex items-center gap-1.5 text-xs">
|
<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 { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
|
import {
|
||||||
|
calculatePasswordStrength,
|
||||||
|
getPasswordRequirements,
|
||||||
|
getPasswordStrengthLabel,
|
||||||
|
getPasswordStrengthColor,
|
||||||
|
} from "@/utils/passwordUtil";
|
||||||
|
|
||||||
type SetPasswordProps = {
|
type SetPasswordProps = {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -70,13 +76,7 @@ export default function SetPasswordDialog({
|
|||||||
const baseSchema = {
|
const baseSchema = {
|
||||||
password: z
|
password: z
|
||||||
.string()
|
.string()
|
||||||
.min(8, t("users.dialog.form.password.requirements.length"))
|
.min(12, 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(),
|
confirmPassword: z.string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,25 +125,13 @@ export default function SetPasswordDialog({
|
|||||||
const confirmPassword = form.watch("confirmPassword");
|
const confirmPassword = form.watch("confirmPassword");
|
||||||
|
|
||||||
// Password strength calculation
|
// Password strength calculation
|
||||||
const passwordStrength = useMemo(() => {
|
const passwordStrength = useMemo(
|
||||||
if (!password) return 0;
|
() => calculatePasswordStrength(password),
|
||||||
|
[password],
|
||||||
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 requirements = useMemo(
|
const requirements = useMemo(
|
||||||
() => ({
|
() => getPasswordRequirements(password),
|
||||||
length: password?.length >= 8,
|
|
||||||
uppercase: /[A-Z]/.test(password || ""),
|
|
||||||
digit: /\d/.test(password || ""),
|
|
||||||
special: /[!@#$%^&*(),.?":{}|<>]/.test(password || ""),
|
|
||||||
}),
|
|
||||||
[password],
|
[password],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -196,25 +184,6 @@ export default function SetPasswordDialog({
|
|||||||
onSave(values.password, oldPassword);
|
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 (
|
return (
|
||||||
<Dialog open={show} onOpenChange={onCancel}>
|
<Dialog open={show} onOpenChange={onCancel}>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
@@ -367,14 +336,16 @@ export default function SetPasswordDialog({
|
|||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-secondary-foreground">
|
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-secondary-foreground">
|
||||||
<div
|
<div
|
||||||
className={`${getStrengthColor()} transition-all duration-300`}
|
className={`${getPasswordStrengthColor(
|
||||||
style={{ width: `${(passwordStrength / 4) * 100}%` }}
|
password,
|
||||||
|
)} transition-all duration-300`}
|
||||||
|
style={{ width: `${passwordStrength * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("users.dialog.form.password.strength.title")}
|
{t("users.dialog.form.password.strength.title")}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{getStrengthLabel()}
|
{getPasswordStrengthLabel(password, t)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -401,60 +372,6 @@ export default function SetPasswordDialog({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ export default function CreateFaceWizardDialog({
|
|||||||
}}
|
}}
|
||||||
regexPattern={/^[\p{L}\p{N}\s'_-]{1,50}$/u}
|
regexPattern={/^[\p{L}\p{N}\s'_-]{1,50}$/u}
|
||||||
regexErrorMessage={t("description.invalidName")}
|
regexErrorMessage={t("description.invalidName")}
|
||||||
|
forbiddenPattern={/#/}
|
||||||
|
forbiddenErrorMessage={t("description.nameCannotContainHash")}
|
||||||
>
|
>
|
||||||
<div className="flex justify-end py-2">
|
<div className="flex justify-end py-2">
|
||||||
<Button variant="select" type="submit">
|
<Button variant="select" type="submit">
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ type TextEntryDialogProps = {
|
|||||||
allowEmpty?: boolean;
|
allowEmpty?: boolean;
|
||||||
regexPattern?: RegExp;
|
regexPattern?: RegExp;
|
||||||
regexErrorMessage?: string;
|
regexErrorMessage?: string;
|
||||||
|
forbiddenPattern?: RegExp;
|
||||||
|
forbiddenErrorMessage?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TextEntryDialog({
|
export default function TextEntryDialog({
|
||||||
@@ -34,6 +36,8 @@ export default function TextEntryDialog({
|
|||||||
allowEmpty = false,
|
allowEmpty = false,
|
||||||
regexPattern,
|
regexPattern,
|
||||||
regexErrorMessage,
|
regexErrorMessage,
|
||||||
|
forbiddenPattern,
|
||||||
|
forbiddenErrorMessage,
|
||||||
}: TextEntryDialogProps) {
|
}: TextEntryDialogProps) {
|
||||||
const { t } = useTranslation("common");
|
const { t } = useTranslation("common");
|
||||||
|
|
||||||
@@ -50,6 +54,8 @@ export default function TextEntryDialog({
|
|||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
regexPattern={regexPattern}
|
regexPattern={regexPattern}
|
||||||
regexErrorMessage={regexErrorMessage}
|
regexErrorMessage={regexErrorMessage}
|
||||||
|
forbiddenPattern={forbiddenPattern}
|
||||||
|
forbiddenErrorMessage={forbiddenErrorMessage}
|
||||||
>
|
>
|
||||||
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
|
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
|
||||||
<Button type="button" onClick={() => setOpen(false)}>
|
<Button type="button" onClick={() => setOpen(false)}>
|
||||||
|
|||||||
@@ -560,6 +560,8 @@ function LibrarySelector({
|
|||||||
defaultValue={renameFace || ""}
|
defaultValue={renameFace || ""}
|
||||||
regexPattern={/^[\p{L}\p{N}\s'_-]{1,50}$/u}
|
regexPattern={/^[\p{L}\p{N}\s'_-]{1,50}$/u}
|
||||||
regexErrorMessage={t("description.invalidName")}
|
regexErrorMessage={t("description.invalidName")}
|
||||||
|
forbiddenPattern={/#/}
|
||||||
|
forbiddenErrorMessage={t("description.nameCannotContainHash")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
|
|||||||
34
web/src/utils/passwordUtil.ts
Normal file
34
web/src/utils/passwordUtil.ts
Normal 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";
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user