From 35a4e86a391632d1f8a4e5798e26731eebdc88f2 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 20 May 2026 11:33:10 -0500 Subject: [PATCH] frontend --- .../theme/widgets/PasswordWidget.tsx | 22 +++++++++++++-- web/src/lib/const.ts | 10 +++++++ web/src/utils/configUtil.ts | 28 +++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/web/src/components/config-form/theme/widgets/PasswordWidget.tsx b/web/src/components/config-form/theme/widgets/PasswordWidget.tsx index 80a4e504ee..a7a09af7b3 100644 --- a/web/src/components/config-form/theme/widgets/PasswordWidget.tsx +++ b/web/src/components/config-form/theme/widgets/PasswordWidget.tsx @@ -3,8 +3,10 @@ import type { WidgetProps } from "@rjsf/utils"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { LuEye, LuEyeOff } from "react-icons/lu"; import { cn } from "@/lib/utils"; +import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const"; import { getSizedFieldClassName } from "../utils"; export function PasswordWidget(props: WidgetProps) { @@ -21,17 +23,31 @@ export function PasswordWidget(props: WidgetProps) { options, } = props; + const { t } = useTranslation(["common"]); const [showPassword, setShowPassword] = useState(false); const fieldClassName = getSizedFieldClassName(options, "sm"); + // When the backend returns the sentinel, hide it visually and prompt the + // user that a value is already saved. The value stays as the sentinel in + // form state — backend /config/set strips it so the saved YAML is + // preserved when the user doesn't touch the field. + const isRedacted = value === REDACTED_CREDENTIAL_SENTINEL; + const displayValue = isRedacted ? "" : (value ?? ""); + const effectivePlaceholder = isRedacted + ? t("credentialField.savedPlaceholder", { + ns: "common", + defaultValue: "Saved — leave blank to keep current", + }) + : placeholder || ""; + return (
onChange(e.target.value === "" ? undefined : e.target.value) } @@ -46,7 +62,7 @@ export function PasswordWidget(props: WidgetProps) { size="sm" className="absolute right-0 top-0 h-full px-3 hover:bg-transparent" onClick={() => setShowPassword(!showPassword)} - disabled={disabled} + disabled={disabled || isRedacted} > {showPassword ? ( diff --git a/web/src/lib/const.ts b/web/src/lib/const.ts index 96aa1f4b18..5db13e375d 100644 --- a/web/src/lib/const.ts +++ b/web/src/lib/const.ts @@ -1,6 +1,16 @@ /** ONNX embedding models that require local model downloads. GenAI providers are not in this list. */ export const JINA_EMBEDDING_MODELS = ["jinav1", "jinav2"] as const; +/** + * Sentinel the backend substitutes for saved credentials (api keys, + * passwords, secrets) in /config responses. The credential widget renders + * this value as an empty input with a "saved — leave blank to keep" hint, + * and stripRedactedCredentials() removes any field still equal to this + * value before sending a config/set payload so the saved YAML value is + * preserved. Mirror of frigate.const.REDACTED_CREDENTIAL_SENTINEL. + */ +export const REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__"; + export const ANNOTATION_OFFSET_MIN = -10000; export const ANNOTATION_OFFSET_MAX = 5000; export const ANNOTATION_OFFSET_STEP = 50; diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index 4b6ffefb71..cb7f6f52b6 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -11,6 +11,7 @@ import isEqual from "lodash/isEqual"; import mergeWith from "lodash/mergeWith"; import set from "lodash/set"; import { isJsonObject } from "@/lib/utils"; +import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const"; import { applySchemaDefaults } from "@/lib/config-schema"; import { normalizeConfigValue } from "@/hooks/use-config-override"; import { @@ -29,6 +30,33 @@ import type { import type { SectionConfig } from "../components/config-form/sections/BaseSection"; import { sectionConfigs } from "../components/config-form/sectionConfigs"; +/** + * Recursively strip any key whose value is the redaction sentinel from a + * config_data payload. Use just before sending to /config/set so untouched + * credential placeholder fields don't clobber the saved YAML value. Mutates + * and returns the input. + */ +export function stripRedactedCredentials(value: T): T { + if (Array.isArray(value)) { + for (const item of value) { + stripRedactedCredentials(item); + } + return value; + } + if (value && typeof value === "object") { + const obj = value as Record; + for (const key of Object.keys(obj)) { + const v = obj[key]; + if (v === REDACTED_CREDENTIAL_SENTINEL) { + delete obj[key]; + } else if (v && typeof v === "object") { + stripRedactedCredentials(v); + } + } + } + return value; +} + // --------------------------------------------------------------------------- // cameraUpdateTopicMap — maps config section paths to MQTT/WS update topics // ---------------------------------------------------------------------------