Add on-demand Frigate+ API key validation (#24918)

* Add on-demand Frigate+ API key validation

The Frigate+ settings page reported the API key as validated whenever it
matched the expected format, without ever checking it with the server.

- Clarify the status text to say the key matches the expected format
- Add PlusApi.validate_key(), which requests a token from Frigate+ and
  reports the key as valid, invalid, unreachable, or not set; server
  errors and timeouts are not reported as an invalid key
- Add an admin-only POST /plus/validate endpoint and regenerate the API spec
- Add a "Validate with Frigate+" button that shows the result inline

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde

* Validate Frigate+ key asynchronously and keep cached token on bad replies

- Use aiohttp for the validation request so the endpoint awaits it
  instead of blocking a worker thread
- Only replace the cached token when the reply has a string accessToken
  and numeric expires; otherwise report unreachable so an unexpected
  response can't break later Frigate+ requests
- Test against a local aiohttp server, covering timeouts, connection
  failures, non-JSON replies and unusable token shapes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde

* Honor proxy settings when validating the Frigate+ key

aiohttp ignores HTTP(S)_PROXY unless trust_env is set, so validation
reported Frigate+ as unreachable on installs that need a proxy while
other Frigate+ requests still worked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Blake Blackshear
2026-11-09 06:30:35 -07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 52d469ed20
commit 81cf7be89a
7 changed files with 380 additions and 17 deletions
+7 -2
View File
@@ -1285,8 +1285,13 @@
},
"apiKey": {
"title": "Frigate+ API Key",
"validated": "Frigate+ API key is detected and validated",
"notValidated": "Frigate+ API key is not detected or not validated",
"formatValid": "Frigate+ API key is detected and matches the expected format",
"formatInvalid": "Frigate+ API key is not detected or does not match the expected format",
"validate": "Validate with Frigate+",
"validating": "Validating…",
"serverValid": "Frigate+ accepted this API key",
"serverInvalid": "Frigate+ rejected this API key",
"serverUnreachable": "Unable to reach Frigate+ to validate this API key",
"desc": "The Frigate+ API key enables integration with the Frigate+ service.",
"plusLink": "Read more about Frigate+"
},
@@ -1,8 +1,9 @@
import { useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import useSWR from "swr";
import { CheckCircle2, XCircle } from "lucide-react";
import axios from "axios";
import { AlertTriangle, CheckCircle2, XCircle } from "lucide-react";
import { LuExternalLink } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
@@ -18,12 +19,34 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { isReplayCamera } from "@/utils/cameraUtil";
import type { SettingsPageProps } from "@/views/settings/SingleSectionPage";
import { getPrimaryModel } from "@/utils/modelUtil";
import { useIsAdmin } from "@/hooks/use-is-admin";
type PlusKeyStatus = "valid" | "invalid" | "unreachable" | "not_set";
type PlusValidateResponse = { success: boolean; status: PlusKeyStatus };
export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
const { t } = useTranslation("views/settings");
const { getLocaleDocUrl } = useDocDomain();
const { data: config } = useSWR<FrigateConfig>("config");
const navigate = useNavigate();
const isAdmin = useIsAdmin();
const [isValidating, setIsValidating] = useState(false);
const [keyStatus, setKeyStatus] = useState<PlusKeyStatus>();
const validateKey = useCallback(async () => {
setIsValidating(true);
try {
const response = await axios.post<PlusValidateResponse>("plus/validate");
setKeyStatus(response.data.status);
} catch {
setKeyStatus("unreachable");
} finally {
setIsValidating(false);
}
}, []);
useEffect(() => {
document.title = t("documentTitle.frigatePlus");
@@ -68,17 +91,55 @@ export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
</>
}
content={
<div className="flex items-center gap-2">
{config?.plus?.enabled ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
{config?.plus?.enabled ? (
<CheckCircle2 className="size-5 shrink-0 text-green-500" />
) : (
<XCircle className="size-5 shrink-0 text-red-500" />
)}
<span className="text-sm">
{config?.plus?.enabled
? t("frigatePlus.apiKey.formatValid")
: t("frigatePlus.apiKey.formatInvalid")}
</span>
</div>
{config?.plus?.enabled && isAdmin && (
<div className="flex flex-col items-start gap-2">
<Button
size="sm"
variant="outline"
disabled={isValidating}
onClick={validateKey}
aria-label={t("frigatePlus.apiKey.validate")}
>
{isValidating && (
<ActivityIndicator className="mr-2" size={16} />
)}
{isValidating
? t("frigatePlus.apiKey.validating")
: t("frigatePlus.apiKey.validate")}
</Button>
{keyStatus && !isValidating && (
<div className="flex items-center gap-2">
{keyStatus === "valid" ? (
<CheckCircle2 className="size-5 shrink-0 text-green-500" />
) : keyStatus === "unreachable" ? (
<AlertTriangle className="size-5 shrink-0 text-yellow-500" />
) : (
<XCircle className="size-5 shrink-0 text-red-500" />
)}
<span className="text-sm">
{keyStatus === "valid"
? t("frigatePlus.apiKey.serverValid")
: keyStatus === "unreachable"
? t("frigatePlus.apiKey.serverUnreachable")
: t("frigatePlus.apiKey.serverInvalid")}
</span>
</div>
)}
</div>
)}
<span className="text-sm">
{config?.plus?.enabled
? t("frigatePlus.apiKey.validated")
: t("frigatePlus.apiKey.notValidated")}
</span>
</div>
}
/>