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
// ---------------------------------------------------------------------------