Files
frigate/web/src/components/overlay/SetPasswordDialog.tsx
T
Josh HawkinsandGitHub 96ce75cf98 Miscellaneous fixes (#24846)
* miscellaneous fixes

- keep base path in recording share links
- reject non-positive scale on recordings/unavailable, which looped forever on the event loop
- reject negative vod event padding, which cleared has_clip
- clamp negative preview gif/mp4 offsets
- don't delete exports that are still in progress
- hash on unknown login usernames so timing doesn't reveal valid users
- include the first minute of the events time-of-day filter
- show an error when the push subscription is refused
- don't wipe an unnamed known plates entry when Add is pressed again
- use elapsed hours for export last N hours so DST doesn't shift the range

* fix live audio settings and trigger highlights

- start each camera group's audio from that group's saved settings instead of merging every group
- don't clear the previous trigger's highlight timeout when another trigger fires

* address review feedback

- Discard the export row when an export fails before or during ffmpeg, so it doesn't stay in progress until a restart
- Skip the empty thumbnail path when discarding a failed export
- Measure preview clip duration from the clamped start
- Default live player volume to 1 when the group has no saved setting
- Clear the unsaved registration message when notification registration fails
- Keep a highlight timer per trigger
- Remove the severity order_by in /review, which the start_time order_by replaced

* Hold pandas 3.1 until numpy 2

* fix two-way talk shortcut and detail stream active item after camera switch

* show already running message when a media sync is in progress

* pick the earliest overlapping preview for preview gif and mp4

* unmute camera audio when starting two-way talk from the shortcut

* remove password strength meter and fix confirm password feedback

The strength meter still scored against the old 8 character and character class rules, so any password with a digit, like test1234, showed a full green "Very Strong" bar next to the failing 12 character requirement. The meter is gone and the length checklist is the only feedback. The password field's form message duplicated the checklist, and editing the password never revalidated the confirm field, so a fixed mismatch kept showing "Passwords do not match". Editing the password now revalidates the confirm field once it has a value, and both dialogs show a live match/mismatch indicator in place of the confirm form message.
2026-11-02 08:11:27 -06:00

469 lines
16 KiB
TypeScript

import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { useState, useEffect, useMemo } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../ui/form";
import { LuCheck, LuX, LuEye, LuEyeOff, LuExternalLink } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain";
import useSWR from "swr";
import { formatSecondsToDuration } from "@/utils/dateUtil";
import { useDateLocale } from "@/hooks/use-date-locale";
import ActivityIndicator from "../indicators/activity-indicator";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { getPasswordRequirements } from "@/utils/passwordUtil";
type SetPasswordProps = {
show: boolean;
onSave: (password: string, oldPassword?: string) => void;
onCancel: () => void;
initialError?: string | null;
username?: string;
isLoading?: boolean;
};
export default function SetPasswordDialog({
show,
onSave,
onCancel,
initialError,
username,
isLoading = false,
}: SetPasswordProps) {
const { t } = useTranslation(["views/settings", "common"]);
const { getLocaleDocUrl } = useDocDomain();
const isAdmin = useIsAdmin();
const dateLocale = useDateLocale();
const { data: config } = useSWR("config");
const refreshSeconds: number | undefined =
config?.auth?.refresh_time ?? undefined;
const refreshTimeLabel = refreshSeconds
? formatSecondsToDuration(refreshSeconds, dateLocale)
: t("time.30minutes", { ns: "common" });
// visibility toggles for password fields
const [showOldPassword, setShowOldPassword] = useState<boolean>(false);
const [showPasswordVisible, setShowPasswordVisible] =
useState<boolean>(false);
const [showConfirmPassword, setShowConfirmPassword] =
useState<boolean>(false);
// Create form schema with conditional old password requirement
const formSchema = useMemo(() => {
const baseSchema = {
password: z
.string()
.min(12, t("users.dialog.form.password.requirements.length")),
confirmPassword: z.string(),
};
if (username) {
return z
.object({
oldPassword: z
.string()
.min(1, t("users.dialog.passwordSetting.currentPasswordRequired")),
...baseSchema,
})
.refine((data) => data.password === data.confirmPassword, {
message: t("users.dialog.passwordSetting.doNotMatch"),
path: ["confirmPassword"],
});
} else {
return z
.object(baseSchema)
.refine((data) => data.password === data.confirmPassword, {
message: t("users.dialog.passwordSetting.doNotMatch"),
path: ["confirmPassword"],
});
}
}, [username, t]);
type FormValues = z.infer<typeof formSchema>;
const defaultValues = username
? {
oldPassword: "",
password: "",
confirmPassword: "",
}
: {
password: "",
confirmPassword: "",
};
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: defaultValues as FormValues,
});
const password = form.watch("password");
const confirmPassword = form.watch("confirmPassword");
const requirements = useMemo(
() => getPasswordRequirements(password),
[password],
);
// Reset form and visibility toggles when dialog opens/closes
useEffect(() => {
if (show) {
form.reset();
setShowOldPassword(false);
setShowPasswordVisible(false);
setShowConfirmPassword(false);
}
}, [show, form]);
// Handle backend errors
useEffect(() => {
if (show && initialError) {
const errorMsg = String(initialError);
// Check if the error is about incorrect current password
if (
errorMsg.toLowerCase().includes("current password is incorrect") ||
errorMsg.toLowerCase().includes("current password incorrect")
) {
if (username) {
form.setError("oldPassword" as keyof FormValues, {
type: "manual",
message: t("users.dialog.passwordSetting.incorrectCurrentPassword"),
});
}
} else {
// For other errors, show as form-level error
form.setError("root", {
type: "manual",
message: errorMsg,
});
}
}
}, [show, initialError, form, t, username]);
const onSubmit = async (values: FormValues) => {
const oldPassword =
"oldPassword" in values
? (
values as {
oldPassword: string;
password: string;
confirmPassword: string;
}
).oldPassword
: undefined;
onSave(values.password, oldPassword);
};
return (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader className="space-y-2">
<DialogTitle>
{username
? t("users.dialog.passwordSetting.updatePassword", {
username,
ns: "views/settings",
})
: t("users.dialog.passwordSetting.setPassword")}
</DialogTitle>
<DialogDescription>
{t("users.dialog.passwordSetting.desc")}
</DialogDescription>
<p className="text-sm text-muted-foreground">
{t("users.dialog.passwordSetting.multiDeviceWarning", {
refresh_time: refreshTimeLabel,
ns: "views/settings",
})}
</p>
{isAdmin && (
<>
<p className="text-sm text-muted-foreground">
{t("users.dialog.passwordSetting.multiDeviceAdmin", {
ns: "views/settings",
})}
</p>
<p className="text-sm text-primary-variant">
<a
href={getLocaleDocUrl(
"configuration/authentication#jwt-token-secret",
)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-primary"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 size-3" />
</a>
</p>
</>
)}
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4 pt-4"
>
{username && (
<FormField
control={form.control}
name={"oldPassword" as keyof FormValues}
render={({ field }) => (
<FormItem>
<FormLabel>
{t("users.dialog.form.currentPassword.title")}
</FormLabel>
<FormControl>
<div className="relative">
<Input
{...field}
type={showOldPassword ? "text" : "password"}
placeholder={t(
"users.dialog.form.currentPassword.placeholder",
)}
className="h-10 pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
tabIndex={-1}
aria-label={
showOldPassword
? 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={() => setShowOldPassword(!showOldPassword)}
>
{showOldPassword ? (
<LuEyeOff className="size-4" />
) : (
<LuEye className="size-4" />
)}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("users.dialog.form.newPassword.title")}
</FormLabel>
<FormControl>
<div className="relative">
<Input
{...field}
type={showPasswordVisible ? "text" : "password"}
placeholder={t(
"users.dialog.form.newPassword.placeholder",
)}
className="h-10 pr-10"
autoFocus
onChange={(e) => {
field.onChange(e);
if (form.getValues("confirmPassword")) {
form.trigger("confirmPassword");
}
}}
/>
<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="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>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("users.dialog.form.password.confirm.title")}
</FormLabel>
<FormControl>
<div className="relative">
<Input
{...field}
type={showConfirmPassword ? "text" : "password"}
placeholder={t(
"users.dialog.form.newPassword.confirm.placeholder",
)}
className="h-10 pr-10"
/>
<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>
{password && confirmPassword && (
<div className="mt-1 flex items-center gap-1.5 text-xs">
{password === confirmPassword ? (
<>
<LuCheck className="size-3.5 text-green-500" />
<span className="text-green-600">
{t("users.dialog.form.password.match")}
</span>
</>
) : (
<>
<LuX className="size-3.5 text-red-500" />
<span className="text-red-600">
{t("users.dialog.form.password.notMatch")}
</span>
</>
)}
</div>
)}
</FormItem>
)}
/>
{form.formState.errors.root && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{form.formState.errors.root.message}
</div>
)}
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
disabled={isLoading}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
type="submit"
disabled={isLoading || !form.formState.isValid}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}