mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-15 18:13:01 +03:00
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:
co-authored by
Claude Opus 5.5
parent
52d469ed20
commit
81cf7be89a
Vendored
+19
@@ -3148,6 +3148,25 @@ paths:
|
|||||||
- frigateUserAuth: []
|
- frigateUserAuth: []
|
||||||
x-required-role: any
|
x-required-role: any
|
||||||
description: '**Access:** Any authenticated user.'
|
description: '**Access:** Any authenticated user.'
|
||||||
|
/plus/validate:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- App
|
||||||
|
summary: Validate Plus Key
|
||||||
|
description: |-
|
||||||
|
**Access:** Admin role required.
|
||||||
|
|
||||||
|
Validate the configured Frigate+ API key with the Frigate+ server.
|
||||||
|
operationId: validate_plus_key_plus_validate_post
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successful Response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {}
|
||||||
|
security:
|
||||||
|
- frigateAdminAuth: []
|
||||||
|
x-required-role: admin
|
||||||
/recognized_license_plates:
|
/recognized_license_plates:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
+11
-1
@@ -54,7 +54,7 @@ from frigate.jobs.media_sync import (
|
|||||||
start_media_sync_job,
|
start_media_sync_job,
|
||||||
)
|
)
|
||||||
from frigate.models import Event, Timeline
|
from frigate.models import Event, Timeline
|
||||||
from frigate.plus import load_plus_model_info
|
from frigate.plus import PlusKeyStatus, load_plus_model_info
|
||||||
from frigate.stats.prometheus import get_metrics, update_metrics
|
from frigate.stats.prometheus import get_metrics, update_metrics
|
||||||
from frigate.types import JobStatusTypesEnum
|
from frigate.types import JobStatusTypesEnum
|
||||||
from frigate.util.builtin import (
|
from frigate.util.builtin import (
|
||||||
@@ -1675,6 +1675,16 @@ def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
|
|||||||
return JSONResponse(content=validModels)
|
return JSONResponse(content=validModels)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/plus/validate", dependencies=[Depends(require_role(["admin"]))])
|
||||||
|
async def validate_plus_key(request: Request):
|
||||||
|
"""Validate the configured Frigate+ API key with the Frigate+ server."""
|
||||||
|
status = await request.app.frigate_config.plus_api.validate_key()
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": status == PlusKeyStatus.valid, "status": status.value}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/recognized_license_plates", dependencies=[Depends(allow_any_authenticated())]
|
"/recognized_license_plates", dependencies=[Depends(allow_any_authenticated())]
|
||||||
)
|
)
|
||||||
|
|||||||
+81
-1
@@ -3,9 +3,11 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, TypeGuard
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
import cv2
|
import cv2
|
||||||
import requests
|
import requests
|
||||||
from numpy import ndarray
|
from numpy import ndarray
|
||||||
@@ -17,6 +19,17 @@ from frigate.const import MODEL_CACHE_DIR, PLUS_API_HOST, PLUS_ENV_VAR
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PLUS_VALIDATE_TIMEOUT = 10
|
||||||
|
|
||||||
|
|
||||||
|
class PlusKeyStatus(str, Enum):
|
||||||
|
"""Result of validating a Frigate+ API key with the server."""
|
||||||
|
|
||||||
|
valid = "valid"
|
||||||
|
invalid = "invalid"
|
||||||
|
unreachable = "unreachable"
|
||||||
|
not_set = "not_set"
|
||||||
|
|
||||||
|
|
||||||
def add_hailo_alias(model_info: dict[str, Any]) -> dict[str, Any]:
|
def add_hailo_alias(model_info: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Name the hailo detector by its current key as well as its old one.
|
"""Name the hailo detector by its current key as well as its old one.
|
||||||
@@ -72,6 +85,19 @@ def get_jpg_bytes(image: ndarray, max_dim: int, quality: int) -> bytes:
|
|||||||
return jpg_bytes if isinstance(jpg_bytes, bytes) else b""
|
return jpg_bytes if isinstance(jpg_bytes, bytes) else b""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_usable_token(token_data: Any) -> TypeGuard[dict[str, Any]]:
|
||||||
|
"""Whether a Frigate+ token response has a usable token and expiry."""
|
||||||
|
if not isinstance(token_data, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
expires = token_data.get("expires")
|
||||||
|
return (
|
||||||
|
isinstance(token_data.get("accessToken"), str)
|
||||||
|
and isinstance(expires, (int, float))
|
||||||
|
and not isinstance(expires, bool)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PlusApi:
|
class PlusApi:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.host = PLUS_API_HOST
|
self.host = PLUS_API_HOST
|
||||||
@@ -153,6 +179,60 @@ class PlusApi:
|
|||||||
def is_active(self) -> bool:
|
def is_active(self) -> bool:
|
||||||
return self._is_active
|
return self._is_active
|
||||||
|
|
||||||
|
async def validate_key(self) -> PlusKeyStatus:
|
||||||
|
"""Check the configured API key against the Frigate+ server.
|
||||||
|
|
||||||
|
The key format is checked at startup, but only the server can tell
|
||||||
|
whether the key is actually valid. This requests a fresh token so the
|
||||||
|
result reflects the key's current state rather than a cached token.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The key's status as reported by the Frigate+ server
|
||||||
|
"""
|
||||||
|
if self.key is None:
|
||||||
|
return PlusKeyStatus.not_set
|
||||||
|
|
||||||
|
key_id, _, secret = self.key.partition(":")
|
||||||
|
|
||||||
|
# Use a single request with a timeout instead of the retrying session,
|
||||||
|
# since this is user initiated and should fail fast when unreachable.
|
||||||
|
# trust_env honors proxy settings, matching the other Frigate+ requests.
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(
|
||||||
|
timeout=aiohttp.ClientTimeout(total=PLUS_VALIDATE_TIMEOUT),
|
||||||
|
trust_env=True,
|
||||||
|
) as session:
|
||||||
|
async with session.get(
|
||||||
|
f"{self.host}/v1/auth/token",
|
||||||
|
auth=aiohttp.BasicAuth(key_id, secret),
|
||||||
|
) as r:
|
||||||
|
status = r.status
|
||||||
|
token_data = await r.json(content_type=None) if r.ok else None
|
||||||
|
except (aiohttp.ClientError, TimeoutError) as err:
|
||||||
|
logger.warning("Unable to reach Frigate+ to validate API key: %s", err)
|
||||||
|
return PlusKeyStatus.unreachable
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Invalid response validating Frigate+ API key")
|
||||||
|
return PlusKeyStatus.unreachable
|
||||||
|
|
||||||
|
if status in (401, 403):
|
||||||
|
return PlusKeyStatus.invalid
|
||||||
|
|
||||||
|
if status >= 400:
|
||||||
|
logger.warning(
|
||||||
|
"Unexpected response validating Frigate+ API key: %s", status
|
||||||
|
)
|
||||||
|
return PlusKeyStatus.unreachable
|
||||||
|
|
||||||
|
# Keep the cached token unless the reply is a usable one, so an
|
||||||
|
# unexpected response can't break later Frigate+ requests
|
||||||
|
if not _is_usable_token(token_data):
|
||||||
|
logger.warning("Invalid response validating Frigate+ API key")
|
||||||
|
return PlusKeyStatus.unreachable
|
||||||
|
|
||||||
|
self._token_data = token_data
|
||||||
|
return PlusKeyStatus.valid
|
||||||
|
|
||||||
def upload_image(self, image: ndarray, camera: str) -> str:
|
def upload_image(self, image: ndarray, camera: str) -> str:
|
||||||
r = self._get("image/signed_urls")
|
r = self._get("image/signed_urls")
|
||||||
if not r.ok:
|
if not r.ok:
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Tests for the Frigate+ API endpoints."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from frigate.models import Event
|
||||||
|
from frigate.plus import PlusKeyStatus
|
||||||
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpPlusValidate(BaseTestHttp):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp([Event])
|
||||||
|
|
||||||
|
def _validate(self, status: PlusKeyStatus) -> dict:
|
||||||
|
app = self.create_app()
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
app.frigate_config.plus_api, "validate_key", return_value=status
|
||||||
|
):
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
response = client.post("/plus/validate")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def test_a_valid_key(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self._validate(PlusKeyStatus.valid), {"success": True, "status": "valid"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_an_invalid_key(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self._validate(PlusKeyStatus.invalid),
|
||||||
|
{"success": False, "status": "invalid"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_viewers_cannot_validate(self):
|
||||||
|
app = self.create_app()
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/plus/validate",
|
||||||
|
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
+138
-1
@@ -1,12 +1,17 @@
|
|||||||
"""Tests for Frigate+ model metadata."""
|
"""Tests for Frigate+ model metadata."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from frigate.plus import PlusApi, load_plus_model_info
|
import aiohttp
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp.test_utils import TestServer
|
||||||
|
|
||||||
|
from frigate.plus import PlusApi, PlusKeyStatus, load_plus_model_info
|
||||||
|
|
||||||
|
|
||||||
class TestHailoAlias(unittest.TestCase):
|
class TestHailoAlias(unittest.TestCase):
|
||||||
@@ -75,5 +80,137 @@ class TestLoadPlusModelInfo(unittest.TestCase):
|
|||||||
self.assertIsNone(load_plus_model_info("bad"))
|
self.assertIsNone(load_plus_model_info("bad"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateKey(unittest.IsolatedAsyncioTestCase):
|
||||||
|
"""The key format is checked locally, but only the server knows if it is valid."""
|
||||||
|
|
||||||
|
CACHED_TOKEN = {"accessToken": "cached", "expires": 2000000000}
|
||||||
|
|
||||||
|
async def _serve(self, handler) -> str:
|
||||||
|
"""Serve the token endpoint locally and return its base url."""
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_get("/v1/auth/token", handler)
|
||||||
|
server = TestServer(app)
|
||||||
|
await server.start_server()
|
||||||
|
self.addAsyncCleanup(server.close)
|
||||||
|
return str(server.make_url("")).rstrip("/")
|
||||||
|
|
||||||
|
def _api(self, host: str, key: str | None = "id:secret") -> PlusApi:
|
||||||
|
api = PlusApi.__new__(PlusApi)
|
||||||
|
api.host = host
|
||||||
|
api.key = key
|
||||||
|
api._token_data = dict(self.CACHED_TOKEN)
|
||||||
|
return api
|
||||||
|
|
||||||
|
async def _validate(self, handler) -> tuple[PlusKeyStatus, PlusApi]:
|
||||||
|
api = self._api(await self._serve(handler))
|
||||||
|
return await api.validate_key(), api
|
||||||
|
|
||||||
|
def _replies(self, status: int = 200, body: object = None, text: str | None = None):
|
||||||
|
async def handler(request: web.Request) -> web.Response:
|
||||||
|
self.auth = request.headers.get("Authorization")
|
||||||
|
if text is not None:
|
||||||
|
return web.Response(status=status, text=text)
|
||||||
|
return web.json_response(body, status=status)
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
async def test_an_accepted_key_is_valid(self):
|
||||||
|
token = {"accessToken": "fresh", "expires": 2000000500.5}
|
||||||
|
status, api = await self._validate(self._replies(body=token))
|
||||||
|
|
||||||
|
self.assertEqual(status, PlusKeyStatus.valid)
|
||||||
|
self.assertEqual(api._token_data, token)
|
||||||
|
self.assertEqual(self.auth, aiohttp.BasicAuth("id", "secret").encode())
|
||||||
|
|
||||||
|
async def test_a_rejected_key_is_invalid(self):
|
||||||
|
for code in (401, 403):
|
||||||
|
status, api = await self._validate(self._replies(code, {"error": "no"}))
|
||||||
|
|
||||||
|
self.assertEqual(status, PlusKeyStatus.invalid)
|
||||||
|
self.assertEqual(api._token_data, self.CACHED_TOKEN)
|
||||||
|
|
||||||
|
async def test_a_server_error_is_not_reported_as_invalid(self):
|
||||||
|
status, _ = await self._validate(self._replies(500, {"error": "down"}))
|
||||||
|
|
||||||
|
self.assertEqual(status, PlusKeyStatus.unreachable)
|
||||||
|
|
||||||
|
async def test_unusable_replies_keep_the_cached_token(self):
|
||||||
|
# a successful reply without a usable token must not replace one that
|
||||||
|
# later Frigate+ requests depend on
|
||||||
|
for body in (
|
||||||
|
None,
|
||||||
|
[],
|
||||||
|
{},
|
||||||
|
{"accessToken": "t"},
|
||||||
|
{"expires": 2000000000},
|
||||||
|
{"accessToken": 1, "expires": 2000000000},
|
||||||
|
{"accessToken": "t", "expires": "soon"},
|
||||||
|
{"accessToken": "t", "expires": True},
|
||||||
|
):
|
||||||
|
with self.subTest(body=body):
|
||||||
|
status, api = await self._validate(self._replies(body=body))
|
||||||
|
|
||||||
|
self.assertEqual(status, PlusKeyStatus.unreachable)
|
||||||
|
self.assertEqual(api._token_data, self.CACHED_TOKEN)
|
||||||
|
|
||||||
|
async def test_a_non_json_reply_keeps_the_cached_token(self):
|
||||||
|
status, api = await self._validate(self._replies(text="<html>"))
|
||||||
|
|
||||||
|
self.assertEqual(status, PlusKeyStatus.unreachable)
|
||||||
|
self.assertEqual(api._token_data, self.CACHED_TOKEN)
|
||||||
|
|
||||||
|
async def test_a_connection_failure_is_unreachable(self):
|
||||||
|
# serve and stop, so the port refuses connections
|
||||||
|
server = TestServer(web.Application())
|
||||||
|
await server.start_server()
|
||||||
|
host = str(server.make_url("")).rstrip("/")
|
||||||
|
await server.close()
|
||||||
|
api = self._api(host)
|
||||||
|
|
||||||
|
self.assertEqual(await api.validate_key(), PlusKeyStatus.unreachable)
|
||||||
|
|
||||||
|
async def test_a_slow_server_times_out_as_unreachable(self):
|
||||||
|
async def slow(request: web.Request) -> web.Response:
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
return web.json_response(self.CACHED_TOKEN)
|
||||||
|
|
||||||
|
with patch("frigate.plus.PLUS_VALIDATE_TIMEOUT", 0.1):
|
||||||
|
status, _ = await self._validate(slow)
|
||||||
|
|
||||||
|
self.assertEqual(status, PlusKeyStatus.unreachable)
|
||||||
|
|
||||||
|
async def test_the_request_goes_through_a_configured_proxy(self):
|
||||||
|
# installs that reach Frigate+ through a proxy must validate the same way
|
||||||
|
token = {"accessToken": "proxied", "expires": 2000000000}
|
||||||
|
proxied = []
|
||||||
|
|
||||||
|
async def proxy(request: web.Request) -> web.Response:
|
||||||
|
proxied.append(str(request.url))
|
||||||
|
return web.json_response(token)
|
||||||
|
|
||||||
|
proxy_url = await self._serve(proxy)
|
||||||
|
api = self._api("http://plus.invalid")
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
os.environ, {"HTTP_PROXY": proxy_url, "NO_PROXY": ""}, clear=False
|
||||||
|
):
|
||||||
|
status = await api.validate_key()
|
||||||
|
|
||||||
|
self.assertEqual(status, PlusKeyStatus.valid)
|
||||||
|
self.assertEqual(proxied, ["http://plus.invalid/v1/auth/token"])
|
||||||
|
|
||||||
|
async def test_no_key_is_not_sent_to_the_server(self):
|
||||||
|
requests_seen = []
|
||||||
|
|
||||||
|
async def handler(request: web.Request) -> web.Response:
|
||||||
|
requests_seen.append(request)
|
||||||
|
return web.json_response(self.CACHED_TOKEN)
|
||||||
|
|
||||||
|
api = self._api(await self._serve(handler), key=None)
|
||||||
|
|
||||||
|
self.assertEqual(await api.validate_key(), PlusKeyStatus.not_set)
|
||||||
|
self.assertEqual(requests_seen, [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
@@ -1285,8 +1285,13 @@
|
|||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Frigate+ API Key",
|
"title": "Frigate+ API Key",
|
||||||
"validated": "Frigate+ API key is detected and validated",
|
"formatValid": "Frigate+ API key is detected and matches the expected format",
|
||||||
"notValidated": "Frigate+ API key is not detected or not validated",
|
"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.",
|
"desc": "The Frigate+ API key enables integration with the Frigate+ service.",
|
||||||
"plusLink": "Read more about Frigate+"
|
"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 { Trans, useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import useSWR from "swr";
|
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 { LuExternalLink } from "react-icons/lu";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -18,12 +19,34 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||||
import type { SettingsPageProps } from "@/views/settings/SingleSectionPage";
|
import type { SettingsPageProps } from "@/views/settings/SingleSectionPage";
|
||||||
import { getPrimaryModel } from "@/utils/modelUtil";
|
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) {
|
export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
|
||||||
const { t } = useTranslation("views/settings");
|
const { t } = useTranslation("views/settings");
|
||||||
const { getLocaleDocUrl } = useDocDomain();
|
const { getLocaleDocUrl } = useDocDomain();
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const navigate = useNavigate();
|
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(() => {
|
useEffect(() => {
|
||||||
document.title = t("documentTitle.frigatePlus");
|
document.title = t("documentTitle.frigatePlus");
|
||||||
@@ -68,17 +91,55 @@ export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
content={
|
content={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-3">
|
||||||
{config?.plus?.enabled ? (
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
{config?.plus?.enabled ? (
|
||||||
) : (
|
<CheckCircle2 className="size-5 shrink-0 text-green-500" />
|
||||||
<XCircle className="h-5 w-5 text-red-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>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user