UI viewer role (#16978)

* db migration

* db model

* assign admin role on password reset

* add role to jwt and api responses

* don't restrict api access for admins yet

* use json response

* frontend auth context

* update auth form for profile endpoint

* add access denied page

* add protected routes

* auth hook

* dialogs

* user settings view

* restrict viewer access to settings

* restrict camera functions for viewer role

* add password dialog to account menu

* spacing tweak

* migrator default to admin

* escape quotes in migrator

* ui tweaks

* tweaks

* colors

* colors

* fix merge conflict

* fix icons

* add api layer enforcement

* ui tweaks

* fix error message

* debug

* clean up

* remove print

* guard apis for admin only

* fix tests

* fix review tests

* use correct error responses from api in toasts

* add role to account menu
This commit is contained in:
Josh Hawkins 2025-03-08 10:01:08 -06:00 committed by GitHub
parent 6f9d9cd5a8
commit 74ca009b0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 1951 additions and 732 deletions

View File

@ -1,14 +1,16 @@
## Send a subrequest to verify if the user is authenticated and has permission to access the resource. ## Send a subrequest to verify if the user is authenticated and has permission to access the resource.
auth_request /auth; auth_request /auth;
## Save the upstream metadata response headers from Authelia to variables. ## Save the upstream metadata response headers from the auth request to variables
auth_request_set $user $upstream_http_remote_user; auth_request_set $user $upstream_http_remote_user;
auth_request_set $role $upstream_http_remote_role;
auth_request_set $groups $upstream_http_remote_groups; auth_request_set $groups $upstream_http_remote_groups;
auth_request_set $name $upstream_http_remote_name; auth_request_set $name $upstream_http_remote_name;
auth_request_set $email $upstream_http_remote_email; auth_request_set $email $upstream_http_remote_email;
## Inject the metadata response headers from the variables into the request made to the backend. ## Inject the metadata response headers from the variables into the request made to the backend.
proxy_set_header Remote-User $user; proxy_set_header Remote-User $user;
proxy_set_header Remote-Role $role;
proxy_set_header Remote-Groups $groups; proxy_set_header Remote-Groups $groups;
proxy_set_header Remote-Email $email; proxy_set_header Remote-Email $email;
proxy_set_header Remote-Name $name; proxy_set_header Remote-Name $name;

View File

@ -22,6 +22,7 @@ from markupsafe import escape
from peewee import operator from peewee import operator
from pydantic import ValidationError from pydantic import ValidationError
from frigate.api.auth import require_role
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
from frigate.api.defs.request.app_body import AppConfigSetBody from frigate.api.defs.request.app_body import AppConfigSetBody
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
@ -201,7 +202,7 @@ def config_raw():
) )
@router.post("/config/save") @router.post("/config/save", dependencies=[Depends(require_role(["admin"]))])
def config_save(save_option: str, body: Any = Body(media_type="text/plain")): def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
new_config = body.decode() new_config = body.decode()
if not new_config: if not new_config:
@ -326,7 +327,7 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
) )
@router.put("/config/set") @router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
def config_set(request: Request, body: AppConfigSetBody): def config_set(request: Request, body: AppConfigSetBody):
config_file = find_config_file() config_file = find_config_file()
@ -542,7 +543,7 @@ async def logs(
) )
@router.post("/restart") @router.post("/restart", dependencies=[Depends(require_role(["admin"]))])
def restart(): def restart():
try: try:
restart_frigate() restart_frigate()

View File

@ -11,8 +11,9 @@ import secrets
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import List
from fastapi import APIRouter, Request, Response from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from joserfc import jwt from joserfc import jwt
from peewee import DoesNotExist from peewee import DoesNotExist
@ -22,6 +23,7 @@ from frigate.api.defs.request.app_body import (
AppPostLoginBody, AppPostLoginBody,
AppPostUsersBody, AppPostUsersBody,
AppPutPasswordBody, AppPutPasswordBody,
AppPutRoleBody,
) )
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.config import AuthConfig, ProxyConfig from frigate.config import AuthConfig, ProxyConfig
@ -169,8 +171,10 @@ def verify_password(password, password_hash):
return secrets.compare_digest(password_hash, compare_hash) return secrets.compare_digest(password_hash, compare_hash)
def create_encoded_jwt(user, expiration, secret): def create_encoded_jwt(user, role, expiration, secret):
return jwt.encode({"alg": "HS256"}, {"sub": user, "exp": expiration}, secret) return jwt.encode(
{"alg": "HS256"}, {"sub": user, "role": role, "exp": expiration}, secret
)
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure): def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure):
@ -184,7 +188,48 @@ def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, sec
) )
# Endpoint for use with nginx auth_request async def get_current_user(request: Request):
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
encoded_token = request.cookies.get(JWT_COOKIE_NAME)
if not encoded_token:
return JSONResponse(content={"message": "No JWT token found"}, status_code=401)
try:
token = jwt.decode(encoded_token, request.app.jwt_token)
if "sub" not in token.claims or "role" not in token.claims:
return JSONResponse(
content={"message": "Invalid JWT token"}, status_code=401
)
return {"username": token.claims["sub"], "role": token.claims["role"]}
except Exception as e:
logger.error(f"Error parsing JWT: {e}")
return JSONResponse(content={"message": "Invalid JWT token"}, status_code=401)
def require_role(required_roles: List[str]):
async def role_checker(request: Request):
# Get role from header (could be comma-separated)
role_header = request.headers.get("remote-role")
roles = [r.strip() for r in role_header.split(",")] if role_header else []
# Check if we have any roles
if not roles:
raise HTTPException(status_code=403, detail="Role not provided")
# Check if any role matches required_roles
if not any(role in required_roles for role in roles):
raise HTTPException(
status_code=403,
detail=f"Role {', '.join(roles)} not authorized. Required: {', '.join(required_roles)}",
)
# Return the first matching role
return next((role for role in roles if role in required_roles), roles[0])
return role_checker
# Endpoints
@router.get("/auth") @router.get("/auth")
def auth(request: Request): def auth(request: Request):
auth_config: AuthConfig = request.app.frigate_config.auth auth_config: AuthConfig = request.app.frigate_config.auth
@ -195,6 +240,8 @@ def auth(request: Request):
# dont require auth if the request is on the internal port # dont require auth if the request is on the internal port
# this header is set by Frigate's nginx proxy, so it cant be spoofed # this header is set by Frigate's nginx proxy, so it cant be spoofed
if int(request.headers.get("x-server-port", default=0)) == 5000: if int(request.headers.get("x-server-port", default=0)) == 5000:
success_response.headers["remote-user"] = "anonymous"
success_response.headers["remote-role"] = "admin"
return success_response return success_response
fail_response = Response("", status_code=401) fail_response = Response("", status_code=401)
@ -211,14 +258,18 @@ def auth(request: Request):
if not auth_config.enabled: if not auth_config.enabled:
# pass the user header value from the upstream proxy if a mapping is specified # pass the user header value from the upstream proxy if a mapping is specified
# or use anonymous if none are specified # or use anonymous if none are specified
if proxy_config.header_map.user is not None: user_header = proxy_config.header_map.user
upstream_user_header_value = request.headers.get( role_header = proxy_config.header_map.get("role", "Remote-Role")
proxy_config.header_map.user, success_response.headers["remote-user"] = (
default="anonymous", request.headers.get(user_header, default="anonymous")
) if user_header
success_response.headers["remote-user"] = upstream_user_header_value else "anonymous"
else: )
success_response.headers["remote-user"] = "anonymous" success_response.headers["remote-role"] = (
request.headers.get(role_header, default="viewer")
if role_header
else "viewer"
)
return success_response return success_response
# now apply authentication # now apply authentication
@ -251,11 +302,15 @@ def auth(request: Request):
if "sub" not in token.claims: if "sub" not in token.claims:
logger.debug("user not set in jwt token") logger.debug("user not set in jwt token")
return fail_response return fail_response
if "role" not in token.claims:
logger.debug("role not set in jwt token")
return fail_response
if "exp" not in token.claims: if "exp" not in token.claims:
logger.debug("exp not set in jwt token") logger.debug("exp not set in jwt token")
return fail_response return fail_response
user = token.claims.get("sub") user = token.claims.get("sub")
role = token.claims.get("role")
current_time = int(time.time()) current_time = int(time.time())
# if the jwt is expired # if the jwt is expired
@ -283,7 +338,7 @@ def auth(request: Request):
return fail_response return fail_response
new_expiration = current_time + JWT_SESSION_LENGTH new_expiration = current_time + JWT_SESSION_LENGTH
new_encoded_jwt = create_encoded_jwt( new_encoded_jwt = create_encoded_jwt(
user, new_expiration, request.app.jwt_token user, role, new_expiration, request.app.jwt_token
) )
set_jwt_cookie( set_jwt_cookie(
success_response, success_response,
@ -294,6 +349,7 @@ def auth(request: Request):
) )
success_response.headers["remote-user"] = user success_response.headers["remote-user"] = user
success_response.headers["remote-role"] = role
return success_response return success_response
except Exception as e: except Exception as e:
logger.error(f"Error parsing jwt: {e}") logger.error(f"Error parsing jwt: {e}")
@ -302,8 +358,16 @@ def auth(request: Request):
@router.get("/profile") @router.get("/profile")
def profile(request: Request): def profile(request: Request):
username = request.headers.get("remote-user") username = request.headers.get("remote-user", "anonymous")
return JSONResponse(content={"username": username}) if username != "anonymous":
try:
user = User.get_by_id(username)
role = getattr(user, "role", "viewer")
except DoesNotExist:
role = "viewer" # Fallback if user deleted
else:
role = None
return JSONResponse(content={"username": username, "role": role})
@router.get("/logout") @router.get("/logout")
@ -333,8 +397,11 @@ def login(request: Request, body: AppPostLoginBody):
password_hash = db_user.password_hash password_hash = db_user.password_hash
if verify_password(password, password_hash): if verify_password(password, password_hash):
role = getattr(db_user, "role", "viewer")
if role not in ["admin", "viewer"]:
role = "viewer" # Enforce valid roles
expiration = int(time.time()) + JWT_SESSION_LENGTH expiration = int(time.time()) + JWT_SESSION_LENGTH
encoded_jwt = create_encoded_jwt(user, expiration, request.app.jwt_token) encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
response = Response("", 200) response = Response("", 200)
set_jwt_cookie( set_jwt_cookie(
response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE
@ -343,25 +410,31 @@ def login(request: Request, body: AppPostLoginBody):
return JSONResponse(content={"message": "Login failed"}, status_code=401) return JSONResponse(content={"message": "Login failed"}, status_code=401)
@router.get("/users") @router.get("/users", dependencies=[Depends(require_role(["admin"]))])
def get_users(): def get_users():
exports = User.select(User.username).order_by(User.username).dicts().iterator() exports = (
User.select(User.username, User.role).order_by(User.username).dicts().iterator()
)
return JSONResponse([e for e in exports]) return JSONResponse([e for e in exports])
@router.post("/users") @router.post("/users", dependencies=[Depends(require_role(["admin"]))])
def create_user(request: Request, body: AppPostUsersBody): def create_user(
request: Request,
body: AppPostUsersBody,
):
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
if not re.match("^[A-Za-z0-9._]+$", body.username): if not re.match("^[A-Za-z0-9._]+$", body.username):
JSONResponse(content={"message": "Invalid username"}, status_code=400) return JSONResponse(content={"message": "Invalid username"}, status_code=400)
role = body.role if body.role in ["admin", "viewer"] else "viewer"
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS) password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
User.insert( User.insert(
{ {
User.username: body.username, User.username: body.username,
User.password_hash: password_hash, User.password_hash: password_hash,
User.role: role,
User.notification_tokens: [], User.notification_tokens: [],
} }
).execute() ).execute()
@ -375,15 +448,61 @@ def delete_user(username: str):
@router.put("/users/{username}/password") @router.put("/users/{username}/password")
def update_password(request: Request, username: str, body: AppPutPasswordBody): async def update_password(
request: Request,
username: str,
body: AppPutPasswordBody,
):
current_user = await get_current_user(request)
if isinstance(current_user, JSONResponse):
# auth failed
return current_user
current_username = current_user.get("username")
current_role = current_user.get("role")
# viewers can only change their own password
if current_role == "viewer" and current_username != username:
raise HTTPException(
status_code=403, detail="Viewers can only update their own password"
)
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS) password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
User.set_by_id(username, {User.password_hash: password_hash})
User.set_by_id( return JSONResponse(content={"success": True})
username,
{
User.password_hash: password_hash, @router.put(
}, "/users/{username}/role",
) dependencies=[Depends(require_role(["admin"]))],
)
async def update_role(
request: Request,
username: str,
body: AppPutRoleBody,
):
current_user = await get_current_user(request)
if isinstance(current_user, JSONResponse):
# auth failed
return current_user
current_role = current_user.get("role")
# viewers can't change anyone's role
if current_role == "viewer":
raise HTTPException(
status_code=403, detail="Admin role is required to change user roles"
)
if username == "admin":
return JSONResponse(
content={"message": "Cannot modify admin user's role"}, status_code=403
)
if body.role not in ["admin", "viewer"]:
return JSONResponse(
content={"message": "Role must be 'admin' or 'viewer'"}, status_code=400
)
User.set_by_id(username, {User.role: body.role})
return JSONResponse(content={"success": True}) return JSONResponse(content={"success": True})

View File

@ -6,12 +6,13 @@ import random
import shutil import shutil
import string import string
from fastapi import APIRouter, Request, UploadFile from fastapi import APIRouter, Depends, Request, UploadFile
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pathvalidate import sanitize_filename from pathvalidate import sanitize_filename
from peewee import DoesNotExist from peewee import DoesNotExist
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.const import FACE_DIR from frigate.const import FACE_DIR
from frigate.embeddings import EmbeddingsContext from frigate.embeddings import EmbeddingsContext
@ -44,7 +45,7 @@ def get_faces():
return JSONResponse(status_code=200, content=face_dict) return JSONResponse(status_code=200, content=face_dict)
@router.post("/faces/reprocess") @router.post("/faces/reprocess", dependencies=[Depends(require_role(["admin"]))])
def reclassify_face(request: Request, body: dict = None): def reclassify_face(request: Request, body: dict = None):
if not request.app.frigate_config.face_recognition.enabled: if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse( return JSONResponse(
@ -121,7 +122,7 @@ def train_face(request: Request, name: str, body: dict = None):
) )
@router.post("/faces/{name}/create") @router.post("/faces/{name}/create", dependencies=[Depends(require_role(["admin"]))])
async def create_face(request: Request, name: str): async def create_face(request: Request, name: str):
if not request.app.frigate_config.face_recognition.enabled: if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse( return JSONResponse(
@ -138,7 +139,7 @@ async def create_face(request: Request, name: str):
) )
@router.post("/faces/{name}/register") @router.post("/faces/{name}/register", dependencies=[Depends(require_role(["admin"]))])
async def register_face(request: Request, name: str, file: UploadFile): async def register_face(request: Request, name: str, file: UploadFile):
if not request.app.frigate_config.face_recognition.enabled: if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse( return JSONResponse(
@ -154,7 +155,7 @@ async def register_face(request: Request, name: str, file: UploadFile):
) )
@router.post("/faces/{name}/delete") @router.post("/faces/{name}/delete", dependencies=[Depends(require_role(["admin"]))])
def deregister_faces(request: Request, name: str, body: dict = None): def deregister_faces(request: Request, name: str, body: dict = None):
if not request.app.frigate_config.face_recognition.enabled: if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse( return JSONResponse(

View File

@ -1,3 +1,5 @@
from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
@ -12,8 +14,13 @@ class AppPutPasswordBody(BaseModel):
class AppPostUsersBody(BaseModel): class AppPostUsersBody(BaseModel):
username: str username: str
password: str password: str
role: Optional[str] = "viewer"
class AppPostLoginBody(BaseModel): class AppPostLoginBody(BaseModel):
user: str user: str
password: str password: str
class AppPutRoleBody(BaseModel):
role: str

View File

@ -14,6 +14,7 @@ from fastapi.responses import JSONResponse
from peewee import JOIN, DoesNotExist, fn, operator from peewee import JOIN, DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_role
from frigate.api.defs.query.events_query_parameters import ( from frigate.api.defs.query.events_query_parameters import (
DEFAULT_TIME_RANGE, DEFAULT_TIME_RANGE,
EventsQueryParams, EventsQueryParams,
@ -708,7 +709,11 @@ def event(event_id: str):
return JSONResponse(content="Event not found", status_code=404) return JSONResponse(content="Event not found", status_code=404)
@router.post("/events/{event_id}/retain", response_model=GenericResponse) @router.post(
"/events/{event_id}/retain",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def set_retain(event_id: str): def set_retain(event_id: str):
try: try:
event = Event.get(Event.id == event_id) event = Event.get(Event.id == event_id)
@ -928,7 +933,11 @@ def false_positive(request: Request, event_id: str):
) )
@router.delete("/events/{event_id}/retain", response_model=GenericResponse) @router.delete(
"/events/{event_id}/retain",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def delete_retain(event_id: str): def delete_retain(event_id: str):
try: try:
event = Event.get(Event.id == event_id) event = Event.get(Event.id == event_id)
@ -947,7 +956,11 @@ def delete_retain(event_id: str):
) )
@router.post("/events/{event_id}/sub_label", response_model=GenericResponse) @router.post(
"/events/{event_id}/sub_label",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def set_sub_label( def set_sub_label(
request: Request, request: Request,
event_id: str, event_id: str,
@ -1022,7 +1035,11 @@ def set_sub_label(
) )
@router.post("/events/{event_id}/description", response_model=GenericResponse) @router.post(
"/events/{event_id}/description",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def set_description( def set_description(
request: Request, request: Request,
event_id: str, event_id: str,
@ -1069,7 +1086,11 @@ def set_description(
) )
@router.put("/events/{event_id}/description/regenerate", response_model=GenericResponse) @router.put(
"/events/{event_id}/description/regenerate",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def regenerate_description( def regenerate_description(
request: Request, event_id: str, params: RegenerateQueryParameters = Depends() request: Request, event_id: str, params: RegenerateQueryParameters = Depends()
): ):
@ -1137,14 +1158,22 @@ def delete_single_event(event_id: str, request: Request) -> dict:
return {"success": True, "message": f"Event {event_id} deleted"} return {"success": True, "message": f"Event {event_id} deleted"}
@router.delete("/events/{event_id}", response_model=GenericResponse) @router.delete(
"/events/{event_id}",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def delete_event(request: Request, event_id: str): def delete_event(request: Request, event_id: str):
result = delete_single_event(event_id, request) result = delete_single_event(event_id, request)
status_code = 200 if result["success"] else 404 status_code = 200 if result["success"] else 404
return JSONResponse(content=result, status_code=status_code) return JSONResponse(content=result, status_code=status_code)
@router.delete("/events/", response_model=EventMultiDeleteResponse) @router.delete(
"/events/",
response_model=EventMultiDeleteResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def delete_events(request: Request, body: EventsDeleteBody): def delete_events(request: Request, body: EventsDeleteBody):
if not body.event_ids: if not body.event_ids:
return JSONResponse( return JSONResponse(
@ -1170,7 +1199,11 @@ def delete_events(request: Request, body: EventsDeleteBody):
return JSONResponse(content=response, status_code=200) return JSONResponse(content=response, status_code=200)
@router.post("/events/{camera_name}/{label}/create", response_model=EventCreateResponse) @router.post(
"/events/{camera_name}/{label}/create",
response_model=EventCreateResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def create_event( def create_event(
request: Request, request: Request,
camera_name: str, camera_name: str,
@ -1226,7 +1259,11 @@ def create_event(
) )
@router.put("/events/{event_id}/end", response_model=GenericResponse) @router.put(
"/events/{event_id}/end",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def end_event(request: Request, event_id: str, body: EventsEndBody): def end_event(request: Request, event_id: str, body: EventsEndBody):
try: try:
end_time = body.end_time or datetime.datetime.now().timestamp() end_time = body.end_time or datetime.datetime.now().timestamp()

View File

@ -6,11 +6,12 @@ import string
from pathlib import Path from pathlib import Path
import psutil import psutil
from fastapi import APIRouter, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from peewee import DoesNotExist from peewee import DoesNotExist
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_role
from frigate.api.defs.request.export_recordings_body import ExportRecordingsBody from frigate.api.defs.request.export_recordings_body import ExportRecordingsBody
from frigate.api.defs.request.export_rename_body import ExportRenameBody from frigate.api.defs.request.export_rename_body import ExportRenameBody
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
@ -130,7 +131,9 @@ def export_recording(
) )
@router.patch("/export/{event_id}/rename") @router.patch(
"/export/{event_id}/rename", dependencies=[Depends(require_role(["admin"]))]
)
def export_rename(event_id: str, body: ExportRenameBody): def export_rename(event_id: str, body: ExportRenameBody):
try: try:
export: Export = Export.get(Export.id == event_id) export: Export = Export.get(Export.id == event_id)
@ -158,7 +161,7 @@ def export_rename(event_id: str, body: ExportRenameBody):
) )
@router.delete("/export/{event_id}") @router.delete("/export/{event_id}", dependencies=[Depends(require_role(["admin"]))])
def export_delete(event_id: str): def export_delete(event_id: str):
try: try:
export: Export = Export.get(Export.id == event_id) export: Export = Export.get(Export.id == event_id)

View File

@ -12,6 +12,7 @@ from fastapi.responses import JSONResponse
from peewee import Case, DoesNotExist, fn, operator from peewee import Case, DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_role
from frigate.api.defs.query.review_query_parameters import ( from frigate.api.defs.query.review_query_parameters import (
ReviewActivityMotionQueryParams, ReviewActivityMotionQueryParams,
ReviewQueryParams, ReviewQueryParams,
@ -343,7 +344,11 @@ def set_multiple_reviewed(body: ReviewModifyMultipleBody):
) )
@router.post("/reviews/delete", response_model=GenericResponse) @router.post(
"/reviews/delete",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def delete_reviews(body: ReviewModifyMultipleBody): def delete_reviews(body: ReviewModifyMultipleBody):
list_of_ids = body.ids list_of_ids = body.ids
reviews = ( reviews = (

View File

@ -620,6 +620,7 @@ class FrigateApp:
) )
User.replace( User.replace(
username="admin", username="admin",
role="admin",
password_hash=password_hash, password_hash=password_hash,
notification_tokens=[], notification_tokens=[],
).execute() ).execute()

View File

@ -12,6 +12,10 @@ class HeaderMappingConfig(FrigateBaseModel):
user: str = Field( user: str = Field(
default=None, title="Header name from upstream proxy to identify user." default=None, title="Header name from upstream proxy to identify user."
) )
role: str = Field(
default=None,
title="Header name from upstream proxy to identify user role.",
)
class ProxyConfig(FrigateBaseModel): class ProxyConfig(FrigateBaseModel):

View File

@ -117,5 +117,9 @@ class RecordingsToDelete(Model): # type: ignore[misc]
class User(Model): # type: ignore[misc] class User(Model): # type: ignore[misc]
username = CharField(null=False, primary_key=True, max_length=30) username = CharField(null=False, primary_key=True, max_length=30)
role = CharField(
max_length=20,
default="viewer",
)
password_hash = CharField(null=False, max_length=120) password_hash = CharField(null=False, max_length=120)
notification_tokens = JSONField() notification_tokens = JSONField()

View File

@ -504,7 +504,7 @@ class TestHttpReview(BaseTestHttp):
def test_post_reviews_delete_no_body(self): def test_post_reviews_delete_no_body(self):
with TestClient(self.app) as client: with TestClient(self.app) as client:
super().insert_mock_review_segment("123456.random") super().insert_mock_review_segment("123456.random")
response = client.post("/reviews/delete") response = client.post("/reviews/delete", headers={"remote-role": "admin"})
# Missing ids # Missing ids
assert response.status_code == 422 assert response.status_code == 422
@ -512,7 +512,9 @@ class TestHttpReview(BaseTestHttp):
with TestClient(self.app) as client: with TestClient(self.app) as client:
super().insert_mock_review_segment("123456.random") super().insert_mock_review_segment("123456.random")
body = {"ids": [""]} body = {"ids": [""]}
response = client.post("/reviews/delete", json=body) response = client.post(
"/reviews/delete", json=body, headers={"remote-role": "admin"}
)
# Missing ids # Missing ids
assert response.status_code == 422 assert response.status_code == 422
@ -521,7 +523,9 @@ class TestHttpReview(BaseTestHttp):
id = "123456.random" id = "123456.random"
super().insert_mock_review_segment(id) super().insert_mock_review_segment(id)
body = {"ids": ["1"]} body = {"ids": ["1"]}
response = client.post("/reviews/delete", json=body) response = client.post(
"/reviews/delete", json=body, headers={"remote-role": "admin"}
)
assert response.status_code == 200 assert response.status_code == 200
response_json = response.json() response_json = response.json()
assert response_json["success"] == True assert response_json["success"] == True
@ -536,7 +540,9 @@ class TestHttpReview(BaseTestHttp):
id = "123456.random" id = "123456.random"
super().insert_mock_review_segment(id) super().insert_mock_review_segment(id)
body = {"ids": [id]} body = {"ids": [id]}
response = client.post("/reviews/delete", json=body) response = client.post(
"/reviews/delete", json=body, headers={"remote-role": "admin"}
)
assert response.status_code == 200 assert response.status_code == 200
response_json = response.json() response_json = response.json()
assert response_json["success"] == True assert response_json["success"] == True
@ -558,7 +564,9 @@ class TestHttpReview(BaseTestHttp):
assert len(recordings_ids_in_db_before) == 2 assert len(recordings_ids_in_db_before) == 2
body = {"ids": ids} body = {"ids": ids}
response = client.post("/reviews/delete", json=body) response = client.post(
"/reviews/delete", json=body, headers={"remote-role": "admin"}
)
assert response.status_code == 200 assert response.status_code == 200
response_json = response.json() response_json = response.json()
assert response_json["success"] == True assert response_json["success"] == True

View File

@ -172,7 +172,7 @@ class TestHttp(unittest.TestCase):
event = client.get(f"/events/{id}").json() event = client.get(f"/events/{id}").json()
assert event assert event
assert event["id"] == id assert event["id"] == id
client.delete(f"/events/{id}") client.delete(f"/events/{id}", headers={"remote-role": "admin"})
event = client.get(f"/events/{id}").json() event = client.get(f"/events/{id}").json()
assert event == "Event not found" assert event == "Event not found"
@ -192,12 +192,12 @@ class TestHttp(unittest.TestCase):
with TestClient(app) as client: with TestClient(app) as client:
_insert_mock_event(id) _insert_mock_event(id)
client.post(f"/events/{id}/retain") client.post(f"/events/{id}/retain", headers={"remote-role": "admin"})
event = client.get(f"/events/{id}").json() event = client.get(f"/events/{id}").json()
assert event assert event
assert event["id"] == id assert event["id"] == id
assert event["retain_indefinitely"] is True assert event["retain_indefinitely"] is True
client.delete(f"/events/{id}/retain") client.delete(f"/events/{id}/retain", headers={"remote-role": "admin"})
event = client.get(f"/events/{id}").json() event = client.get(f"/events/{id}").json()
assert event assert event
assert event["id"] == id assert event["id"] == id
@ -262,6 +262,7 @@ class TestHttp(unittest.TestCase):
new_sub_label_response = client.post( new_sub_label_response = client.post(
f"/events/{id}/sub_label", f"/events/{id}/sub_label",
json={"subLabel": sub_label}, json={"subLabel": sub_label},
headers={"remote-role": "admin"},
) )
assert new_sub_label_response.status_code == 200 assert new_sub_label_response.status_code == 200
event = client.get(f"/events/{id}").json() event = client.get(f"/events/{id}").json()
@ -271,6 +272,7 @@ class TestHttp(unittest.TestCase):
empty_sub_label_response = client.post( empty_sub_label_response = client.post(
f"/events/{id}/sub_label", f"/events/{id}/sub_label",
json={"subLabel": ""}, json={"subLabel": ""},
headers={"remote-role": "admin"},
) )
assert empty_sub_label_response.status_code == 200 assert empty_sub_label_response.status_code == 200
event = client.get(f"/events/{id}").json() event = client.get(f"/events/{id}").json()
@ -298,6 +300,7 @@ class TestHttp(unittest.TestCase):
client.post( client.post(
f"/events/{id}/sub_label", f"/events/{id}/sub_label",
json={"subLabel": sub_label}, json={"subLabel": sub_label},
headers={"remote-role": "admin"},
) )
sub_labels = client.get("/sub_labels").json() sub_labels = client.get("/sub_labels").json()
assert sub_labels assert sub_labels

View File

@ -0,0 +1,37 @@
"""Peewee migrations -- 029_add_user_role.py.
Some examples (model - class or model name)::
> Model = migrator.orm['model_name'] # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.python(func, *args, **kwargs) # Run python code
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.drop_index(model, *col_names)
> migrator.add_not_null(model, *field_names)
> migrator.drop_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
"""
import peewee as pw
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.sql(
'ALTER TABLE "user" ADD COLUMN "role" VARCHAR(20) NOT NULL DEFAULT \'admin\''
)
migrator.sql('UPDATE "user" SET "role" = \'admin\' WHERE "role" IS NULL')
def rollback(migrator, database, fake=False, **kwargs):
migrator.sql('ALTER TABLE "user" DROP COLUMN "role"')

View File

@ -10,6 +10,8 @@ import { Suspense, lazy } from "react";
import { Redirect } from "./components/navigation/Redirect"; import { Redirect } from "./components/navigation/Redirect";
import { cn } from "./lib/utils"; import { cn } from "./lib/utils";
import { isPWA } from "./utils/isPWA"; import { isPWA } from "./utils/isPWA";
import ProtectedRoute from "@/components/auth/ProtectedRoute";
import { AuthProvider } from "@/context/auth-context";
const Live = lazy(() => import("@/pages/Live")); const Live = lazy(() => import("@/pages/Live"));
const Events = lazy(() => import("@/pages/Events")); const Events = lazy(() => import("@/pages/Events"));
@ -21,45 +23,58 @@ const Settings = lazy(() => import("@/pages/Settings"));
const UIPlayground = lazy(() => import("@/pages/UIPlayground")); const UIPlayground = lazy(() => import("@/pages/UIPlayground"));
const FaceLibrary = lazy(() => import("@/pages/FaceLibrary")); const FaceLibrary = lazy(() => import("@/pages/FaceLibrary"));
const Logs = lazy(() => import("@/pages/Logs")); const Logs = lazy(() => import("@/pages/Logs"));
const AccessDenied = lazy(() => import("@/pages/AccessDenied"));
function App() { function App() {
return ( return (
<Providers> <Providers>
<BrowserRouter basename={window.baseUrl}> <AuthProvider>
<Wrapper> <BrowserRouter basename={window.baseUrl}>
<div className="size-full overflow-hidden"> <Wrapper>
{isDesktop && <Sidebar />} <div className="size-full overflow-hidden">
{isDesktop && <Statusbar />} {isDesktop && <Sidebar />}
{isMobile && <Bottombar />} {isDesktop && <Statusbar />}
<div {isMobile && <Bottombar />}
id="pageRoot" <div
className={cn( id="pageRoot"
"absolute right-0 top-0 overflow-hidden", className={cn(
isMobile "absolute right-0 top-0 overflow-hidden",
? `bottom-${isPWA ? 16 : 12} left-0 md:bottom-16 landscape:bottom-14 landscape:md:bottom-16` isMobile
: "bottom-8 left-[52px]", ? `bottom-${isPWA ? 16 : 12} left-0 md:bottom-16 landscape:bottom-14 landscape:md:bottom-16`
)} : "bottom-8 left-[52px]",
> )}
<Suspense> >
<Routes> <Suspense>
<Route index element={<Live />} /> <Routes>
<Route path="/events" element={<Redirect to="/review" />} /> <Route
<Route path="/review" element={<Events />} /> element={
<Route path="/explore" element={<Explore />} /> <ProtectedRoute requiredRoles={["viewer", "admin"]} />
<Route path="/export" element={<Exports />} /> }
<Route path="/system" element={<System />} /> >
<Route path="/settings" element={<Settings />} /> <Route index element={<Live />} />
<Route path="/config" element={<ConfigEditor />} /> <Route path="/review" element={<Events />} />
<Route path="/logs" element={<Logs />} /> <Route path="/explore" element={<Explore />} />
<Route path="/playground" element={<UIPlayground />} /> <Route path="/export" element={<Exports />} />
<Route path="/faces" element={<FaceLibrary />} /> <Route path="/settings" element={<Settings />} />
<Route path="*" element={<Redirect to="/" />} /> </Route>
</Routes> <Route
</Suspense> element={<ProtectedRoute requiredRoles={["admin"]} />}
>
<Route path="/system" element={<System />} />
<Route path="/config" element={<ConfigEditor />} />
<Route path="/logs" element={<Logs />} />
<Route path="/faces" element={<FaceLibrary />} />
<Route path="/playground" element={<UIPlayground />} />
</Route>
<Route path="/unauthorized" element={<AccessDenied />} />
<Route path="*" element={<Redirect to="/" />} />
</Routes>
</Suspense>
</div>
</div> </div>
</div> </Wrapper>
</Wrapper> </BrowserRouter>
</BrowserRouter> </AuthProvider>
</Providers> </Providers>
); );
} }

View File

@ -20,24 +20,23 @@ import {
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { AuthContext } from "@/context/auth-context";
interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {} interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {}
export function UserAuthForm({ className, ...props }: UserAuthFormProps) { export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
const [isLoading, setIsLoading] = React.useState<boolean>(false); const [isLoading, setIsLoading] = React.useState<boolean>(false);
const { login } = React.useContext(AuthContext);
const formSchema = z.object({ const formSchema = z.object({
user: z.string(), user: z.string().min(1, "Username is required"),
password: z.string(), password: z.string().min(1, "Password is required"),
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
mode: "onChange", mode: "onChange",
defaultValues: { defaultValues: { user: "", password: "" },
user: "",
password: "",
},
}); });
const onSubmit = async (values: z.infer<typeof formSchema>) => { const onSubmit = async (values: z.infer<typeof formSchema>) => {
@ -50,11 +49,14 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
password: values.password, password: values.password,
}, },
{ {
headers: { headers: { "X-CSRF-TOKEN": 1 },
"X-CSRF-TOKEN": 1,
},
}, },
); );
const profileRes = await axios.get("/profile", { withCredentials: true });
login({
username: profileRes.data.username,
role: profileRes.data.role || "viewer",
});
window.location.href = baseUrl; window.location.href = baseUrl;
} catch (error) { } catch (error) {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {

View File

@ -0,0 +1,40 @@
import { useContext } from "react";
import { Navigate, Outlet } from "react-router-dom";
import { AuthContext } from "@/context/auth-context";
import ActivityIndicator from "../indicators/activity-indicator";
export default function ProtectedRoute({
requiredRoles,
}: {
requiredRoles: ("admin" | "viewer")[];
}) {
const { auth } = useContext(AuthContext);
if (auth.isLoading) {
return (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
);
}
// Unauthenticated mode
if (!auth.isAuthenticated) {
return <Outlet />;
}
// Authenticated mode (8971): require login
if (!auth.user) {
return <Navigate to="/login" replace />;
}
// If role is null (shouldnt happen if isAuthenticated, but type safety), fallback
// though isAuthenticated should catch this
if (auth.user.role === null) {
return <Outlet />;
}
if (!requiredRoles.includes(auth.user.role)) {
return <Navigate to="/unauthorized" replace />;
}
return <Outlet />;
}

View File

@ -281,10 +281,13 @@ function NewGroupDialog({
.catch((error) => { .catch((error) => {
setOpen(false); setOpen(false);
setEditState("none"); setEditState("none");
toast.error( const errorMessage =
`Failed to save config changes: ${error.response.data.message}`, error.response?.data?.message ||
{ position: "top-center" }, error.response?.data?.detail ||
); "Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
position: "top-center",
});
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
@ -725,10 +728,13 @@ export function CameraGroupEdit({
} }
}) })
.catch((error) => { .catch((error) => {
toast.error( const errorMessage =
`Failed to save config changes: ${error.response.data.message}`, error.response?.data?.message ||
{ position: "top-center" }, error.response?.data?.detail ||
); "Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
position: "top-center",
});
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);

View File

@ -44,8 +44,12 @@ export default function SearchActionGroup({
pullLatestData(); pullLatestData();
} }
}) })
.catch(() => { .catch((error) => {
toast.error("Failed to delete tracked objects.", { const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to delete tracked objects.: ${errorMessage}`, {
position: "top-center", position: "top-center",
}); });
}); });

View File

@ -18,22 +18,52 @@ import {
} from "../ui/dropdown-menu"; } from "../ui/dropdown-menu";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { DialogClose } from "../ui/dialog"; import { DialogClose } from "../ui/dialog";
import { LuLogOut } from "react-icons/lu"; import { LuLogOut, LuSquarePen } from "react-icons/lu";
import useSWR from "swr"; import useSWR from "swr";
import { useState } from "react";
import axios from "axios";
import { toast } from "sonner";
import SetPasswordDialog from "../overlay/SetPasswordDialog";
type AccountSettingsProps = { type AccountSettingsProps = {
className?: string; className?: string;
}; };
export default function AccountSettings({ className }: AccountSettingsProps) { export default function AccountSettings({ className }: AccountSettingsProps) {
const { data: profile } = useSWR("profile"); const { data: profile } = useSWR("profile");
const { data: config } = useSWR("config"); const { data: config } = useSWR("config");
const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`; const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`;
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const Container = isDesktop ? DropdownMenu : Drawer; const Container = isDesktop ? DropdownMenu : Drawer;
const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger; const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
const Content = isDesktop ? DropdownMenuContent : DrawerContent; const Content = isDesktop ? DropdownMenuContent : DrawerContent;
const MenuItem = isDesktop ? DropdownMenuItem : DialogClose; const MenuItem = isDesktop ? DropdownMenuItem : DialogClose;
const handlePasswordSave = async (password: string) => {
if (!profile?.username || profile.username === "anonymous") return;
axios
.put(`users/${profile.username}/password`, { password })
.then((response) => {
if (response.status === 200) {
setPasswordDialogOpen(false);
toast.success("Password updated successfully.", {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Error setting password: ${errorMessage}`, {
position: "top-center",
});
});
};
return ( return (
<Container modal={!isDesktop}> <Container modal={!isDesktop}>
<Trigger> <Trigger>
@ -65,9 +95,22 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
> >
<div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden"> <div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden">
<DropdownMenuLabel> <DropdownMenuLabel>
Current User: {profile?.username || "anonymous"} Current User: {profile?.username || "anonymous"}{" "}
{profile?.role && `(${profile.role})`}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} /> <DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} />
{profile?.username && profile.username !== "anonymous" && (
<MenuItem
className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
aria-label="Set Password"
onClick={() => setPasswordDialogOpen(true)}
>
<LuSquarePen className="mr-2 size-4" />
<span>Set Password</span>
</MenuItem>
)}
<MenuItem <MenuItem
className={ className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm" isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
@ -81,6 +124,12 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
</MenuItem> </MenuItem>
</div> </div>
</Content> </Content>
<SetPasswordDialog
show={passwordDialogOpen}
onSave={handlePasswordSave}
onCancel={() => setPasswordDialogOpen(false)}
username={profile?.username}
/>
</Container> </Container>
); );
} }

View File

@ -24,7 +24,6 @@ import {
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "../ui/dropdown-menu"; } from "../ui/dropdown-menu";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { CgDarkMode } from "react-icons/cg"; import { CgDarkMode } from "react-icons/cg";
import { import {
@ -33,10 +32,8 @@ import {
useTheme, useTheme,
} from "@/context/theme-provider"; } from "@/context/theme-provider";
import { IoColorPalette } from "react-icons/io5"; import { IoColorPalette } from "react-icons/io5";
import { useState } from "react"; import { useState } from "react";
import { useRestart } from "@/api/ws"; import { useRestart } from "@/api/ws";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -55,21 +52,27 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import useSWR from "swr"; import useSWR from "swr";
import RestartDialog from "../overlay/dialog/RestartDialog"; import RestartDialog from "../overlay/dialog/RestartDialog";
import { useIsAdmin } from "@/hooks/use-is-admin";
import SetPasswordDialog from "../overlay/SetPasswordDialog";
import { toast } from "sonner";
import axios from "axios";
type GeneralSettingsProps = { type GeneralSettingsProps = {
className?: string; className?: string;
}; };
export default function GeneralSettings({ className }: GeneralSettingsProps) { export default function GeneralSettings({ className }: GeneralSettingsProps) {
const { data: profile } = useSWR("profile"); const { data: profile } = useSWR("profile");
const { data: config } = useSWR("config"); const { data: config } = useSWR("config");
const logoutUrl = config?.proxy?.logout_url || "/api/logout"; const logoutUrl = config?.proxy?.logout_url || "/api/logout";
// settings
const { theme, colorScheme, setTheme, setColorScheme } = useTheme(); const { theme, colorScheme, setTheme, setColorScheme } = useTheme();
const [restartDialogOpen, setRestartDialogOpen] = useState(false); const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const { send: sendRestart } = useRestart(); const { send: sendRestart } = useRestart();
const isAdmin = useIsAdmin();
const Container = isDesktop ? DropdownMenu : Drawer; const Container = isDesktop ? DropdownMenu : Drawer;
const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger; const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
const Content = isDesktop ? DropdownMenuContent : DrawerContent; const Content = isDesktop ? DropdownMenuContent : DrawerContent;
@ -79,6 +82,29 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
const SubItemContent = isDesktop ? DropdownMenuSubContent : DialogContent; const SubItemContent = isDesktop ? DropdownMenuSubContent : DialogContent;
const Portal = isDesktop ? DropdownMenuPortal : DialogPortal; const Portal = isDesktop ? DropdownMenuPortal : DialogPortal;
const handlePasswordSave = async (password: string) => {
if (!profile?.username || profile.username === "anonymous") return;
axios
.put(`users/${profile.username}/password`, { password })
.then((response) => {
if (response.status === 200) {
setPasswordDialogOpen(false);
toast.success("Password updated successfully.", {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Error setting password: ${errorMessage}`, {
position: "top-center",
});
});
};
return ( return (
<> <>
<Container modal={!isDesktop}> <Container modal={!isDesktop}>
@ -121,13 +147,28 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
> >
<div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden"> <div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden">
{isMobile && ( {isMobile && (
<> <div className="mb-2">
<DropdownMenuLabel> <DropdownMenuLabel>
Current User: {profile?.username || "anonymous"} Current User: {profile?.username || "anonymous"}{" "}
{profile?.role && `(${profile.role})`}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator <DropdownMenuSeparator
className={isDesktop ? "mt-3" : "mt-1"} className={isDesktop ? "mt-3" : "mt-1"}
/> />
{profile?.username && profile.username !== "anonymous" && (
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label="Set Password"
onClick={() => setPasswordDialogOpen(true)}
>
<LuSquarePen className="mr-2 size-4" />
<span>Set Password</span>
</MenuItem>
)}
<MenuItem <MenuItem
className={ className={
isDesktop isDesktop
@ -141,39 +182,45 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
<span>Logout</span> <span>Logout</span>
</a> </a>
</MenuItem> </MenuItem>
</div>
)}
{isAdmin && (
<>
<DropdownMenuLabel>System</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}>
<Link to="/system#general">
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
aria-label="System metrics"
>
<LuActivity className="mr-2 size-4" />
<span>System metrics</span>
</MenuItem>
</Link>
<Link to="/logs">
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
aria-label="System logs"
>
<LuList className="mr-2 size-4" />
<span>System logs</span>
</MenuItem>
</Link>
</DropdownMenuGroup>
</> </>
)} )}
<DropdownMenuLabel>System</DropdownMenuLabel> <DropdownMenuLabel
<DropdownMenuSeparator /> className={isDesktop && isAdmin ? "mt-3" : "mt-1"}
<DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}> >
<Link to="/system#general">
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
aria-label="System metrics"
>
<LuActivity className="mr-2 size-4" />
<span>System metrics</span>
</MenuItem>
</Link>
<Link to="/logs">
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
aria-label="System logs"
>
<LuList className="mr-2 size-4" />
<span>System logs</span>
</MenuItem>
</Link>
</DropdownMenuGroup>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Configuration Configuration
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@ -191,143 +238,143 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
<span>Settings</span> <span>Settings</span>
</MenuItem> </MenuItem>
</Link> </Link>
<Link to="/config"> {isAdmin && (
<MenuItem <>
className={ <Link to="/config">
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
aria-label="Configuration editor"
>
<LuSquarePen className="mr-2 size-4" />
<span>Configuration editor</span>
</MenuItem>
</Link>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Appearance
</DropdownMenuLabel>
<DropdownMenuSeparator />
<SubItem>
<SubItemTrigger
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
>
<LuSunMoon className="mr-2 size-4" />
<span>Dark Mode</span>
</SubItemTrigger>
<Portal>
<SubItemContent
className={
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
}
>
<span tabIndex={0} className="sr-only" />
<MenuItem <MenuItem
className={ className={
isDesktop isDesktop
? "cursor-pointer" ? "cursor-pointer"
: "flex items-center p-2 text-sm" : "flex w-full items-center p-2 text-sm"
} }
aria-label="Light mode" aria-label="Configuration editor"
onClick={() => setTheme("light")}
> >
{theme === "light" ? ( <LuSquarePen className="mr-2 size-4" />
<> <span>Configuration editor</span>
<LuSun className="mr-2 size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
Light
</>
) : (
<span className="ml-6 mr-2">Light</span>
)}
</MenuItem> </MenuItem>
<MenuItem </Link>
className={ </>
isDesktop )}
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label="Dark mode"
onClick={() => setTheme("dark")}
>
{theme === "dark" ? (
<>
<LuMoon className="mr-2 size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
Dark
</>
) : (
<span className="ml-6 mr-2">Dark</span>
)}
</MenuItem>
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label="Use the system settings for light or dark mode"
onClick={() => setTheme("system")}
>
{theme === "system" ? (
<>
<CgDarkMode className="mr-2 size-4 scale-100 transition-all" />
System
</>
) : (
<span className="ml-6 mr-2">System</span>
)}
</MenuItem>
</SubItemContent>
</Portal>
</SubItem>
<SubItem>
<SubItemTrigger
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
>
<LuSunMoon className="mr-2 size-4" />
<span>Theme</span>
</SubItemTrigger>
<Portal>
<SubItemContent
className={
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
}
>
<span tabIndex={0} className="sr-only" />
{colorSchemes.map((scheme) => (
<MenuItem
key={scheme}
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label={`Color scheme - ${scheme}`}
onClick={() => setColorScheme(scheme)}
>
{scheme === colorScheme ? (
<>
<IoColorPalette className="mr-2 size-4 rotate-0 scale-100 transition-all" />
{friendlyColorSchemeName(scheme)}
</>
) : (
<span className="ml-6 mr-2">
{friendlyColorSchemeName(scheme)}
</span>
)}
</MenuItem>
))}
</SubItemContent>
</Portal>
</SubItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Appearance
</DropdownMenuLabel>
<DropdownMenuSeparator />
<SubItem>
<SubItemTrigger
className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
>
<LuSunMoon className="mr-2 size-4" />
<span>Dark Mode</span>
</SubItemTrigger>
<Portal>
<SubItemContent
className={
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
}
>
<span tabIndex={0} className="sr-only" />
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label="Light mode"
onClick={() => setTheme("light")}
>
{theme === "light" ? (
<>
<LuSun className="mr-2 size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
Light
</>
) : (
<span className="ml-6 mr-2">Light</span>
)}
</MenuItem>
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label="Dark mode"
onClick={() => setTheme("dark")}
>
{theme === "dark" ? (
<>
<LuMoon className="mr-2 size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
Dark
</>
) : (
<span className="ml-6 mr-2">Dark</span>
)}
</MenuItem>
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label="Use the system settings for light or dark mode"
onClick={() => setTheme("system")}
>
{theme === "system" ? (
<>
<CgDarkMode className="mr-2 size-4 scale-100 transition-all" />
System
</>
) : (
<span className="ml-6 mr-2">System</span>
)}
</MenuItem>
</SubItemContent>
</Portal>
</SubItem>
<SubItem>
<SubItemTrigger
className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
>
<LuSunMoon className="mr-2 size-4" />
<span>Theme</span>
</SubItemTrigger>
<Portal>
<SubItemContent
className={
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
}
>
<span tabIndex={0} className="sr-only" />
{colorSchemes.map((scheme) => (
<MenuItem
key={scheme}
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label={`Color scheme - ${scheme}`}
onClick={() => setColorScheme(scheme)}
>
{scheme === colorScheme ? (
<>
<IoColorPalette className="mr-2 size-4 rotate-0 scale-100 transition-all" />
{friendlyColorSchemeName(scheme)}
</>
) : (
<span className="ml-6 mr-2">
{friendlyColorSchemeName(scheme)}
</span>
)}
</MenuItem>
))}
</SubItemContent>
</Portal>
</SubItem>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}> <DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Help Help
</DropdownMenuLabel> </DropdownMenuLabel>
@ -357,17 +404,25 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
<span>GitHub</span> <span>GitHub</span>
</MenuItem> </MenuItem>
</a> </a>
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} /> {isAdmin && (
<MenuItem <>
className={ <DropdownMenuSeparator
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm" className={isDesktop ? "mt-3" : "mt-1"}
} />
aria-label="Restart Frigate" <MenuItem
onClick={() => setRestartDialogOpen(true)} className={
> isDesktop
<LuRotateCw className="mr-2 size-4" /> ? "cursor-pointer"
<span>Restart Frigate</span> : "flex items-center p-2 text-sm"
</MenuItem> }
aria-label="Restart Frigate"
onClick={() => setRestartDialogOpen(true)}
>
<LuRotateCw className="mr-2 size-4" />
<span>Restart Frigate</span>
</MenuItem>
</>
)}
</div> </div>
</Content> </Content>
</Container> </Container>
@ -376,6 +431,12 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
onClose={() => setRestartDialogOpen(false)} onClose={() => setRestartDialogOpen(false)}
onRestart={() => sendRestart("restart")} onRestart={() => sendRestart("restart")}
/> />
<SetPasswordDialog
show={passwordDialogOpen}
onSave={handlePasswordSave}
onCancel={() => setPasswordDialogOpen(false)}
username={profile?.username}
/>
</> </>
); );
} }

View File

@ -74,8 +74,12 @@ export default function SearchResultActions({
refreshResults(); refreshResults();
} }
}) })
.catch(() => { .catch((error) => {
toast.error("Failed to delete tracked object.", { const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to delete tracked object: ${errorMessage}`, {
position: "top-center", position: "top-center",
}); });
}); });

View File

@ -2,6 +2,7 @@ import { Button } from "../ui/button";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@ -12,20 +13,31 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { useState } from "react"; import { useEffect, useState } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "../ui/dialog"; } from "../ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Shield, User } from "lucide-react";
import { LuCheck, LuX } from "react-icons/lu";
type CreateUserOverlayProps = { type CreateUserOverlayProps = {
show: boolean; show: boolean;
onCreate: (user: string, password: string) => void; onCreate: (user: string, password: string, role: "admin" | "viewer") => void;
onCancel: () => void; onCancel: () => void;
}; };
export default function CreateUserDialog({ export default function CreateUserDialog({
show, show,
onCreate, onCreate,
@ -33,15 +45,22 @@ export default function CreateUserDialog({
}: CreateUserOverlayProps) { }: CreateUserOverlayProps) {
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const formSchema = z.object({ const formSchema = z
user: z .object({
.string() user: z
.min(1) .string()
.regex(/^[A-Za-z0-9._]+$/, { .min(1, "Username is required")
message: "Username may only include letters, numbers, . or _", .regex(/^[A-Za-z0-9._]+$/, {
}), message: "Username may only include letters, numbers, . or _",
password: z.string(), }),
}); password: z.string().min(1, "Password is required"),
confirmPassword: z.string().min(1, "Please confirm your password"),
role: z.enum(["admin", "viewer"]),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@ -49,32 +68,93 @@ export default function CreateUserDialog({
defaultValues: { defaultValues: {
user: "", user: "",
password: "", password: "",
confirmPassword: "",
role: "viewer",
}, },
}); });
const onSubmit = async (values: z.infer<typeof formSchema>) => { const onSubmit = async (values: z.infer<typeof formSchema>) => {
setIsLoading(true); setIsLoading(true);
await onCreate(values.user, values.password); await onCreate(values.user, values.password, values.role);
form.reset(); form.reset();
setIsLoading(false); setIsLoading(false);
}; };
// Check if passwords match for real-time feedback
const password = form.watch("password");
const confirmPassword = form.watch("confirmPassword");
const passwordsMatch = password === confirmPassword;
const showMatchIndicator = password && confirmPassword;
useEffect(() => {
if (!show) {
form.reset({
user: "",
password: "",
role: "viewer",
});
}
}, [show, form]);
const handleCancel = () => {
form.reset({
user: "",
password: "",
role: "viewer",
});
onCancel();
};
return ( return (
<Dialog open={show} onOpenChange={onCancel}> <Dialog open={show} onOpenChange={onCancel}>
<DialogContent> <DialogContent className="sm:max-w-[425px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Create User</DialogTitle> <DialogTitle>Create New User</DialogTitle>
<DialogDescription>
Add a new user account and specify an role for access to areas of
the Frigate UI.
</DialogDescription>
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}> <form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 py-4"
>
<FormField <FormField
name="user" name="user"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>User</FormLabel> <FormLabel className="text-sm font-medium">
Username
</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" placeholder="Enter username"
className="h-10"
{...field}
/>
</FormControl>
<FormDescription className="text-xs text-muted-foreground">
Only letters, numbers, periods and underscores allowed.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
Password
</FormLabel>
<FormControl>
<Input
placeholder="Enter password"
type="password"
className="h-10"
{...field} {...field}
/> />
</FormControl> </FormControl>
@ -82,30 +162,121 @@ export default function CreateUserDialog({
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
name="password" name="confirmPassword"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Password</FormLabel> <FormLabel className="text-sm font-medium">
Confirm Password
</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" placeholder="Confirm password"
type="password" type="password"
className="h-10"
{...field} {...field}
/> />
</FormControl> </FormControl>
{showMatchIndicator && (
<div className="mt-1 flex items-center gap-1.5 text-xs">
{passwordsMatch ? (
<>
<LuCheck className="size-3.5 text-green-500" />
<span className="text-green-600">
Passwords match
</span>
</>
) : (
<>
<LuX className="size-3.5 text-red-500" />
<span className="text-red-600">
Passwords don't match
</span>
</>
)}
</div>
)}
<FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<DialogFooter className="mt-4">
<Button <FormField
variant="select" name="role"
aria-label="Create user" render={({ field }) => (
disabled={isLoading} <FormItem>
> <FormLabel className="text-sm font-medium">Role</FormLabel>
{isLoading && <ActivityIndicator className="mr-2 h-4 w-4" />} <Select
Create User onValueChange={field.onChange}
</Button> defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="h-10">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem
value="admin"
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-primary" />
<span>Admin</span>
</div>
</SelectItem>
<SelectItem
value="viewer"
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>Viewer</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormDescription className="text-xs text-muted-foreground">
Admins have full access to all features in the Frigate UI.
Viewers are limited to viewing cameras, review items, and
historical footage in the UI.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label="Cancel"
disabled={isLoading}
onClick={handleCancel}
type="button"
>
Cancel
</Button>
<Button
variant="select"
aria-label="Save"
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
</div>
) : (
"Save"
)}
</Button>
</div>
</div>
</DialogFooter> </DialogFooter>
</form> </form>
</Form> </Form>

View File

@ -6,34 +6,61 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "../ui/dialog"; } from "../ui/dialog";
import { DialogDescription } from "@radix-ui/react-dialog";
type SetPasswordProps = { type DeleteUserDialogProps = {
show: boolean; show: boolean;
username?: string;
onDelete: () => void; onDelete: () => void;
onCancel: () => void; onCancel: () => void;
}; };
export default function DeleteUserDialog({ export default function DeleteUserDialog({
show, show,
username,
onDelete, onDelete,
onCancel, onCancel,
}: SetPasswordProps) { }: DeleteUserDialogProps) {
return ( return (
<Dialog open={show} onOpenChange={onCancel}> <Dialog open={show} onOpenChange={onCancel}>
<DialogContent> <DialogContent className="sm:max-w-[425px]">
<DialogHeader> <DialogHeader className="flex flex-col items-center gap-2 sm:items-start">
<DialogTitle>Delete User</DialogTitle> <div className="space-y-1 text-center sm:text-left">
<DialogTitle>Delete User</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the
user account and remove all associated data.
</DialogDescription>
</div>
</DialogHeader> </DialogHeader>
<div>Are you sure?</div>
<DialogFooter> <div className="my-4 rounded-md border border-destructive/20 bg-destructive/5 p-4 text-center text-sm">
<Button <p className="font-medium text-destructive">
className="flex items-center gap-1" Are you sure you want to delete{" "}
aria-label="Confirm delete" <span className="font-bold">{username}</span>?
variant="destructive" </p>
size="sm" </div>
onClick={onDelete}
> <DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
Delete <div className="flex flex-1 flex-col justify-end">
</Button> <div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label="Cancel"
onClick={onCancel}
type="button"
>
Cancel
</Button>
<Button
variant="destructive"
aria-label="Delete"
className="flex flex-1"
onClick={onDelete}
>
Delete User
</Button>
</div>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -99,16 +99,13 @@ export default function ExportDialog({
} }
}) })
.catch((error) => { .catch((error) => {
if (error.response?.data?.message) { const errorMessage =
toast.error( error.response?.data?.message ||
`Failed to start export: ${error.response.data.message}`, error.response?.data?.detail ||
{ position: "top-center" }, "Unknown error";
); toast.error(`Failed to start export: ${errorMessage}`, {
} else { position: "top-center",
toast.error(`Failed to start export: ${error.message}`, { });
position: "top-center",
});
}
}); });
}, [camera, name, range, setRange, setName, setMode]); }, [camera, name, range, setRange, setName, setMode]);

View File

@ -106,16 +106,13 @@ export default function MobileReviewSettingsDrawer({
} }
}) })
.catch((error) => { .catch((error) => {
if (error.response?.data?.message) { const errorMessage =
toast.error( error.response?.data?.message ||
`Failed to start export: ${error.response.data.message}`, error.response?.data?.detail ||
{ position: "top-center" }, "Unknown error";
); toast.error(`Failed to start export: ${errorMessage}`, {
} else { position: "top-center",
toast.error(`Failed to start export: ${error.message}`, { });
position: "top-center",
});
}
}); });
}, [camera, name, range, setRange, setName, setMode]); }, [camera, name, range, setRange, setName, setMode]);

View File

@ -0,0 +1,119 @@
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { useState } from "react";
import { LuShield, LuUser } from "react-icons/lu";
type RoleChangeDialogProps = {
show: boolean;
username: string;
currentRole: "admin" | "viewer";
onSave: (role: "admin" | "viewer") => void;
onCancel: () => void;
};
export default function RoleChangeDialog({
show,
username,
currentRole,
onSave,
onCancel,
}: RoleChangeDialogProps) {
const [selectedRole, setSelectedRole] = useState<"admin" | "viewer">(
currentRole,
);
return (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">
Change User Role
</DialogTitle>
<DialogDescription>
Update permissions for{" "}
<span className="font-medium">{username}</span>
</DialogDescription>
</DialogHeader>
<div className="py-6">
<div className="mb-4 text-sm text-muted-foreground">
<p>Select the appropriate role for this user:</p>
<ul className="mt-2 space-y-1 pl-5">
<li>
<span className="font-medium">Admin:</span> Full access to all
features.
</li>
<li>
<span className="font-medium">Viewer:</span> Limited to Live
dashboards, Review, Explore, and Exports only.
</li>
</ul>
</div>
<Select
value={selectedRole}
onValueChange={(value) =>
setSelectedRole(value as "admin" | "viewer")
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin" className="flex items-center gap-2">
<div className="flex items-center gap-2">
<LuShield className="size-4 text-primary" />
<span>Admin</span>
</div>
</SelectItem>
<SelectItem value="viewer" className="flex items-center gap-2">
<div className="flex items-center gap-2">
<LuUser className="size-4 text-primary" />
<span>Viewer</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter className="flex gap-3 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label="Cancel"
onClick={onCancel}
type="button"
>
Cancel
</Button>
<Button
variant="select"
aria-label="Save"
className="flex flex-1"
onClick={() => onSave(selectedRole)}
disabled={selectedRole === currentRole}
>
Save
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,50 +1,202 @@
"use client";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import { useState } from "react"; import { useState, useEffect } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "../ui/dialog"; } from "../ui/dialog";
import { Label } from "../ui/label";
import { LuCheck, LuX } from "react-icons/lu";
type SetPasswordProps = { type SetPasswordProps = {
show: boolean; show: boolean;
onSave: (password: string) => void; onSave: (password: string) => void;
onCancel: () => void; onCancel: () => void;
username?: string;
}; };
export default function SetPasswordDialog({ export default function SetPasswordDialog({
show, show,
onSave, onSave,
onCancel, onCancel,
username,
}: SetPasswordProps) { }: SetPasswordProps) {
const [password, setPassword] = useState<string>(); const [password, setPassword] = useState<string>("");
const [confirmPassword, setConfirmPassword] = useState<string>("");
const [passwordStrength, setPasswordStrength] = useState<number>(0);
const [error, setError] = useState<string | null>(null);
// Reset state when dialog opens/closes
useEffect(() => {
if (show) {
setPassword("");
setConfirmPassword("");
setError(null);
}
}, [show]);
// Simple password strength calculation
useEffect(() => {
if (!password) {
setPasswordStrength(0);
return;
}
let strength = 0;
// Length check
if (password.length >= 8) strength += 1;
// Contains number
if (/\d/.test(password)) strength += 1;
// Contains special char
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 1;
// Contains uppercase
if (/[A-Z]/.test(password)) strength += 1;
setPasswordStrength(strength);
}, [password]);
const handleSave = () => {
if (!password) {
setError("Password cannot be empty");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
onSave(password);
};
const getStrengthLabel = () => {
if (!password) return "";
if (passwordStrength <= 1) return "Weak";
if (passwordStrength === 2) return "Medium";
if (passwordStrength === 3) return "Strong";
return "Very Strong";
};
const getStrengthColor = () => {
if (!password) return "bg-gray-200";
if (passwordStrength <= 1) return "bg-red-500";
if (passwordStrength === 2) return "bg-yellow-500";
if (passwordStrength === 3) return "bg-green-500";
return "bg-green-600";
};
return ( return (
<Dialog open={show} onOpenChange={onCancel}> <Dialog open={show} onOpenChange={onCancel}>
<DialogContent onOpenAutoFocus={(e) => e.preventDefault()}> <DialogContent className="sm:max-w-[425px]">
<DialogHeader> <DialogHeader className="space-y-2">
<DialogTitle>Set Password</DialogTitle> <DialogTitle>
{username ? `Update Password for ${username}` : "Set Password"}
</DialogTitle>
<DialogDescription>
Create a strong password to secure this account.
</DialogDescription>
</DialogHeader> </DialogHeader>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" <div className="space-y-4 py-4">
type="password" <div className="space-y-2">
value={password} <Label htmlFor="password">New Password</Label>
onChange={(event) => setPassword(event.target.value)} <Input
/> id="password"
<DialogFooter> className="h-10"
<Button type="password"
className="flex items-center gap-1" value={password}
aria-label="Save Password" onChange={(event) => {
variant="select" setPassword(event.target.value);
size="sm" setError(null);
onClick={() => { }}
onSave(password!); placeholder="Enter new password"
}} autoFocus
> />
Save
</Button> {/* Password strength indicator */}
{password && (
<div className="mt-2 space-y-1">
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-secondary-foreground">
<div
className={`${getStrengthColor()} transition-all duration-300`}
style={{ width: `${(passwordStrength / 3) * 100}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
Password strength:{" "}
<span className="font-medium">{getStrengthLabel()}</span>
</p>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm Password</Label>
<Input
id="confirm-password"
className="h-10"
type="password"
value={confirmPassword}
onChange={(event) => {
setConfirmPassword(event.target.value);
setError(null);
}}
placeholder="Confirm new password"
/>
{/* Password match indicator */}
{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">Passwords match</span>
</>
) : (
<>
<LuX className="size-3.5 text-red-500" />
<span className="text-red-600">Passwords don't match</span>
</>
)}
</div>
)}
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
</div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label="Cancel"
onClick={onCancel}
type="button"
>
Cancel
</Button>
<Button
variant="select"
aria-label="Save"
className="flex flex-1"
onClick={handleSave}
disabled={!password || password !== confirmPassword}
>
Save
</Button>
</div>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -87,10 +87,13 @@ export function AnnotationSettingsPane({
} }
}) })
.catch((error) => { .catch((error) => {
toast.error( const errorMessage =
`Failed to save config changes: ${error.response.data.message}`, error.response?.data?.message ||
{ position: "top-center" }, error.response?.data?.detail ||
); "Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
position: "top-center",
});
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);

View File

@ -394,8 +394,12 @@ function ObjectDetailsTab({
}, },
); );
}) })
.catch(() => { .catch((error) => {
toast.error("Failed to update the description", { const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to update the description: ${errorMessage}`, {
position: "top-center", position: "top-center",
}); });
setDesc(search.data.description); setDesc(search.data.description);
@ -422,11 +426,13 @@ function ObjectDetailsTab({
} }
}) })
.catch((error) => { .catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error( toast.error(
`Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${error.response.data.message}`, `Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${errorMessage}`,
{ { position: "top-center" },
position: "top-center",
},
); );
}); });
}, },
@ -492,8 +498,12 @@ function ObjectDetailsTab({
setIsSubLabelDialogOpen(false); setIsSubLabelDialogOpen(false);
} }
}) })
.catch(() => { .catch((error) => {
toast.error("Failed to update sub label.", { const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to update sub label: ${errorMessage}`, {
position: "top-center", position: "top-center",
}); });
}); });

View File

@ -176,10 +176,13 @@ export default function MotionMaskEditPane({
} }
}) })
.catch((error) => { .catch((error) => {
toast.error( const errorMessage =
`Failed to save config changes: ${error.response.data.message}`, error.response?.data?.message ||
{ position: "top-center" }, error.response?.data?.detail ||
); "Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
position: "top-center",
});
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);

View File

@ -208,10 +208,13 @@ export default function ObjectMaskEditPane({
} }
}) })
.catch((error) => { .catch((error) => {
toast.error( const errorMessage =
`Failed to save config changes: ${error.response.data.message}`, error.response?.data?.message ||
{ position: "top-center" }, error.response?.data?.detail ||
); "Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
position: "top-center",
});
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);

View File

@ -186,10 +186,13 @@ export default function PolygonItem({
} }
}) })
.catch((error) => { .catch((error) => {
toast.error( const errorMessage =
`Failed to save config changes: ${error.response.data.message}`, error.response?.data?.message ||
{ position: "top-center" }, error.response?.data?.detail ||
); "Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
position: "top-center",
});
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);

View File

@ -414,10 +414,13 @@ export default function ZoneEditPane({
} }
}) })
.catch((error) => { .catch((error) => {
toast.error( const errorMessage =
`Failed to save config changes: ${error.response.data.message}`, error.response?.data?.message ||
{ position: "top-center" }, error.response?.data?.detail ||
); "Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
position: "top-center",
});
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);

View File

@ -0,0 +1,74 @@
import axios from "axios";
import { createContext, useEffect, useState } from "react";
import useSWR from "swr";
interface AuthState {
user: { username: string; role: "admin" | "viewer" | null } | null;
isLoading: boolean;
isAuthenticated: boolean; // true if auth is required
}
interface AuthContextType {
auth: AuthState;
login: (user: AuthState["user"]) => void;
logout: () => void;
}
export const AuthContext = createContext<AuthContextType>({
auth: { user: null, isLoading: true, isAuthenticated: false },
login: () => {},
logout: () => {},
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [auth, setAuth] = useState<AuthState>({
user: null,
isLoading: true,
isAuthenticated: false,
});
const { data: profile, error } = useSWR("/profile", {
revalidateOnFocus: false,
revalidateOnReconnect: true,
fetcher: (url) =>
axios.get(url, { withCredentials: true }).then((res) => res.data),
});
useEffect(() => {
if (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
// auth required but not logged in
setAuth({ user: null, isLoading: false, isAuthenticated: true });
}
return;
}
if (profile) {
if (profile.username && profile.username !== "anonymous") {
const newUser = {
username: profile.username,
role: profile.role || "viewer",
};
setAuth({ user: newUser, isLoading: false, isAuthenticated: true });
} else {
// Unauthenticated mode (anonymous)
setAuth({ user: null, isLoading: false, isAuthenticated: false });
}
}
}, [profile, error]);
const login = (user: AuthState["user"]) => {
setAuth({ user, isLoading: false, isAuthenticated: true });
};
const logout = () => {
setAuth({ user: null, isLoading: false, isAuthenticated: true });
axios.get("/logout", { withCredentials: true });
};
return (
<AuthContext.Provider value={{ auth, login, logout }}>
{children}
</AuthContext.Provider>
);
}

View File

@ -6,6 +6,7 @@ import { IconContext } from "react-icons";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { StatusBarMessagesProvider } from "@/context/statusbar-provider"; import { StatusBarMessagesProvider } from "@/context/statusbar-provider";
import { StreamingSettingsProvider } from "./streaming-settings-provider"; import { StreamingSettingsProvider } from "./streaming-settings-provider";
import { AuthProvider } from "./auth-context";
type TProvidersProps = { type TProvidersProps = {
children: ReactNode; children: ReactNode;
@ -14,19 +15,21 @@ type TProvidersProps = {
function providers({ children }: TProvidersProps) { function providers({ children }: TProvidersProps) {
return ( return (
<RecoilRoot> <RecoilRoot>
<ApiProvider> <AuthProvider>
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme"> <ApiProvider>
<TooltipProvider> <ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
<IconContext.Provider value={{ size: "20" }}> <TooltipProvider>
<StatusBarMessagesProvider> <IconContext.Provider value={{ size: "20" }}>
<StreamingSettingsProvider> <StatusBarMessagesProvider>
{children} <StreamingSettingsProvider>
</StreamingSettingsProvider> {children}
</StatusBarMessagesProvider> </StreamingSettingsProvider>
</IconContext.Provider> </StatusBarMessagesProvider>
</TooltipProvider> </IconContext.Provider>
</ThemeProvider> </TooltipProvider>
</ApiProvider> </ThemeProvider>
</ApiProvider>
</AuthProvider>
</RecoilRoot> </RecoilRoot>
); );
} }

View File

@ -0,0 +1,10 @@
import { useContext } from "react";
import { AuthContext } from "@/context/auth-context";
export function useIsAdmin() {
const { auth } = useContext(AuthContext);
const isAdmin =
(auth.isAuthenticated && auth.user?.role === "admin") ||
auth.user?.role === undefined;
return isAdmin;
}

View File

@ -0,0 +1,21 @@
import Heading from "@/components/ui/heading";
import { useEffect } from "react";
import { FaExclamationTriangle } from "react-icons/fa";
export default function AccessDenied() {
useEffect(() => {
document.title = "Access Denied - Frigate";
}, []);
return (
<div className="flex min-h-screen flex-col items-center justify-center text-center">
<FaExclamationTriangle className="mb-4 size-8" />
<Heading as="h2" className="mb-2">
Access Denied
</Heading>
<p className="text-primary-variant">
You don't have permission to view this page.
</p>
</div>
);
}

View File

@ -59,11 +59,12 @@ function ConfigEditor() {
.catch((error) => { .catch((error) => {
toast.error("Error saving config", { position: "top-center" }); toast.error("Error saving config", { position: "top-center" });
if (error.response) { const errorMessage =
setError(error.response.data.message); error.response?.data?.message ||
} else { error.response?.data?.detail ||
setError(error.message); "Unknown error";
}
setError(errorMessage);
}); });
}, },
[editorRef], [editorRef],

View File

@ -93,16 +93,13 @@ function Exports() {
} }
}) })
.catch((error) => { .catch((error) => {
if (error.response?.data?.message) { const errorMessage =
toast.error( error.response?.data?.message ||
`Failed to rename export: ${error.response.data.message}`, error.response?.data?.detail ||
{ position: "top-center" }, "Unknown error";
); toast.error(`Failed to rename export: ${errorMessage}`, {
} else { position: "top-center",
toast.error(`Failed to rename export: ${error.message}`, { });
position: "top-center",
});
}
}); });
}, },
[mutate], [mutate],

View File

@ -99,16 +99,13 @@ export default function FaceLibrary() {
} }
}) })
.catch((error) => { .catch((error) => {
if (error.response?.data?.message) { const errorMessage =
toast.error( error.response?.data?.message ||
`Failed to upload image: ${error.response.data.message}`, error.response?.data?.detail ||
{ position: "top-center" }, "Unknown error";
); toast.error(`Failed to upload image: ${errorMessage}`, {
} else { position: "top-center",
toast.error(`Failed to upload image: ${error.message}`, { });
position: "top-center",
});
}
}); });
}, },
[pageToggle, refreshFaces], [pageToggle, refreshFaces],
@ -132,16 +129,13 @@ export default function FaceLibrary() {
} }
}) })
.catch((error) => { .catch((error) => {
if (error.response?.data?.message) { const errorMessage =
toast.error( error.response?.data?.message ||
`Failed to set face name: ${error.response.data.message}`, error.response?.data?.detail ||
{ position: "top-center" }, "Unknown error";
); toast.error(`Failed to set face name: ${errorMessage}`, {
} else { position: "top-center",
toast.error(`Failed to set face name: ${error.message}`, { });
position: "top-center",
});
}
}); });
}, },
[refreshFaces], [refreshFaces],
@ -308,15 +302,13 @@ function FaceAttempt({
} }
}) })
.catch((error) => { .catch((error) => {
if (error.response?.data?.message) { const errorMessage =
toast.error(`Failed to train: ${error.response.data.message}`, { error.response?.data?.message ||
position: "top-center", error.response?.data?.detail ||
}); "Unknown error";
} else { toast.error(`Failed to train: ${errorMessage}`, {
toast.error(`Failed to train: ${error.message}`, { position: "top-center",
position: "top-center", });
});
}
}); });
}, },
[image, onRefresh], [image, onRefresh],
@ -334,18 +326,13 @@ function FaceAttempt({
} }
}) })
.catch((error) => { .catch((error) => {
if (error.response?.data?.message) { const errorMessage =
toast.error( error.response?.data?.message ||
`Failed to update score: ${error.response.data.message}`, error.response?.data?.detail ||
{ "Unknown error";
position: "top-center", toast.error(`Failed to update face score: ${errorMessage}`, {
}, position: "top-center",
); });
} else {
toast.error(`Failed to update score: ${error.message}`, {
position: "top-center",
});
}
}); });
}, [image, onRefresh]); }, [image, onRefresh]);
@ -361,15 +348,13 @@ function FaceAttempt({
} }
}) })
.catch((error) => { .catch((error) => {
if (error.response?.data?.message) { const errorMessage =
toast.error(`Failed to delete: ${error.response.data.message}`, { error.response?.data?.message ||
position: "top-center", error.response?.data?.detail ||
}); "Unknown error";
} else { toast.error(`Failed to delete: ${errorMessage}`, {
toast.error(`Failed to delete: ${error.message}`, { position: "top-center",
position: "top-center", });
});
}
}); });
}, [image, onRefresh]); }, [image, onRefresh]);
@ -478,15 +463,13 @@ function FaceImage({ name, image, onRefresh }: FaceImageProps) {
} }
}) })
.catch((error) => { .catch((error) => {
if (error.response?.data?.message) { const errorMessage =
toast.error(`Failed to delete: ${error.response.data.message}`, { error.response?.data?.message ||
position: "top-center", error.response?.data?.detail ||
}); "Unknown error";
} else { toast.error(`Failed to delete: ${errorMessage}`, {
toast.error(`Failed to delete: ${error.message}`, { position: "top-center",
position: "top-center", });
});
}
}); });
}, [name, image, onRefresh]); }, [name, image, onRefresh]);

View File

@ -40,6 +40,7 @@ import UiSettingsView from "@/views/settings/UiSettingsView";
import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useInitialCameraState } from "@/api/ws"; import { useInitialCameraState } from "@/api/ws";
import { useIsAdmin } from "@/hooks/use-is-admin";
const allSettingsViews = [ const allSettingsViews = [
"UI settings", "UI settings",
@ -62,6 +63,15 @@ export default function Settings() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
// auth and roles
const isAdmin = useIsAdmin();
const allowedViewsForViewer: SettingsType[] = ["UI settings", "debug"];
const visibleSettingsViews = !isAdmin
? allowedViewsForViewer
: allSettingsViews;
// TODO: confirm leave page // TODO: confirm leave page
const [unsavedChanges, setUnsavedChanges] = useState(false); const [unsavedChanges, setUnsavedChanges] = useState(false);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
@ -149,7 +159,12 @@ export default function Settings() {
useSearchEffect("page", (page: string) => { useSearchEffect("page", (page: string) => {
if (allSettingsViews.includes(page as SettingsType)) { if (allSettingsViews.includes(page as SettingsType)) {
setPage(page as SettingsType); // Restrict viewer to UI settings
if (!isAdmin && !["UI settings", "debug"].includes(page)) {
setPage("UI settings");
} else {
setPage(page as SettingsType);
}
} }
// don't clear url params if we're creating a new object mask // don't clear url params if we're creating a new object mask
return !searchParams.has("object_mask"); return !searchParams.has("object_mask");
@ -180,11 +195,16 @@ export default function Settings() {
value={pageToggle} value={pageToggle}
onValueChange={(value: SettingsType) => { onValueChange={(value: SettingsType) => {
if (value) { if (value) {
setPageToggle(value); // Restrict viewer navigation
if (!isAdmin && !["UI settings", "debug"].includes(value)) {
setPageToggle("UI settings");
} else {
setPageToggle(value);
}
} }
}} }}
> >
{Object.values(allSettingsViews).map((item) => ( {visibleSettingsViews.map((item) => (
<ToggleGroupItem <ToggleGroupItem
key={item} key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "UI settings" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`} className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "UI settings" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}

View File

@ -1,3 +1,4 @@
export type User = { export type User = {
username: string; username: string;
role: string;
}; };

View File

@ -204,16 +204,13 @@ export default function EventView({
} }
}) })
.catch((error) => { .catch((error) => {
if (error.response?.data?.message) { const errorMessage =
toast.error( error.response?.data?.message ||
`Failed to start export: ${error.response.data.message}`, error.response?.data?.detail ||
{ position: "top-center" }, "Unknown error";
); toast.error(`Failed to start export: ${errorMessage}`, {
} else { position: "top-center",
toast.error(`Failed to start export: ${error.message}`, { });
position: "top-center",
});
}
}); });
}, },
[reviewItems], [reviewItems],

View File

@ -116,6 +116,7 @@ import { Switch } from "@/components/ui/switch";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { useIsAdmin } from "@/hooks/use-is-admin";
type LiveCameraViewProps = { type LiveCameraViewProps = {
config?: FrigateConfig; config?: FrigateConfig;
@ -982,6 +983,10 @@ function FrigateCameraFeatures({
const { payload: autotrackingState, send: sendAutotracking } = const { payload: autotrackingState, send: sendAutotracking } =
useAutotrackingState(camera.name); useAutotrackingState(camera.name);
// roles
const isAdmin = useIsAdmin();
// manual event // manual event
const recordingEventIdRef = useRef<string | null>(null); const recordingEventIdRef = useRef<string | null>(null);
@ -1080,65 +1085,71 @@ function FrigateCameraFeatures({
if (isDesktop || isTablet) { if (isDesktop || isTablet) {
return ( return (
<> <>
<CameraFeatureToggle {isAdmin && (
className="p-2 md:p-0" <>
variant={fullscreen ? "overlay" : "primary"} <CameraFeatureToggle
Icon={enabledState == "ON" ? LuPower : LuPowerOff} className="p-2 md:p-0"
isActive={enabledState == "ON"} variant={fullscreen ? "overlay" : "primary"}
title={`${enabledState == "ON" ? "Disable" : "Enable"} Camera`} Icon={enabledState == "ON" ? LuPower : LuPowerOff}
onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")} isActive={enabledState == "ON"}
disabled={false} title={`${enabledState == "ON" ? "Disable" : "Enable"} Camera`}
/> onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")}
<CameraFeatureToggle disabled={false}
className="p-2 md:p-0" />
variant={fullscreen ? "overlay" : "primary"} <CameraFeatureToggle
Icon={detectState == "ON" ? MdPersonSearch : MdPersonOff} className="p-2 md:p-0"
isActive={detectState == "ON"} variant={fullscreen ? "overlay" : "primary"}
title={`${detectState == "ON" ? "Disable" : "Enable"} Detect`} Icon={detectState == "ON" ? MdPersonSearch : MdPersonOff}
onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")} isActive={detectState == "ON"}
disabled={!cameraEnabled} title={`${detectState == "ON" ? "Disable" : "Enable"} Detect`}
/> onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
<CameraFeatureToggle disabled={!cameraEnabled}
className="p-2 md:p-0" />
variant={fullscreen ? "overlay" : "primary"} <CameraFeatureToggle
Icon={recordState == "ON" ? LuVideo : LuVideoOff} className="p-2 md:p-0"
isActive={recordState == "ON"} variant={fullscreen ? "overlay" : "primary"}
title={`${recordState == "ON" ? "Disable" : "Enable"} Recording`} Icon={recordState == "ON" ? LuVideo : LuVideoOff}
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")} isActive={recordState == "ON"}
disabled={!cameraEnabled} title={`${recordState == "ON" ? "Disable" : "Enable"} Recording`}
/> onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
<CameraFeatureToggle disabled={!cameraEnabled}
className="p-2 md:p-0" />
variant={fullscreen ? "overlay" : "primary"} <CameraFeatureToggle
Icon={snapshotState == "ON" ? MdPhotoCamera : MdNoPhotography} className="p-2 md:p-0"
isActive={snapshotState == "ON"} variant={fullscreen ? "overlay" : "primary"}
title={`${snapshotState == "ON" ? "Disable" : "Enable"} Snapshots`} Icon={snapshotState == "ON" ? MdPhotoCamera : MdNoPhotography}
onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")} isActive={snapshotState == "ON"}
disabled={!cameraEnabled} title={`${snapshotState == "ON" ? "Disable" : "Enable"} Snapshots`}
/> onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")}
{audioDetectEnabled && ( disabled={!cameraEnabled}
<CameraFeatureToggle />
className="p-2 md:p-0" {audioDetectEnabled && (
variant={fullscreen ? "overlay" : "primary"} <CameraFeatureToggle
Icon={audioState == "ON" ? LuEar : LuEarOff} className="p-2 md:p-0"
isActive={audioState == "ON"} variant={fullscreen ? "overlay" : "primary"}
title={`${audioState == "ON" ? "Disable" : "Enable"} Audio Detect`} Icon={audioState == "ON" ? LuEar : LuEarOff}
onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")} isActive={audioState == "ON"}
disabled={!cameraEnabled} title={`${audioState == "ON" ? "Disable" : "Enable"} Audio Detect`}
/> onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
)} disabled={!cameraEnabled}
{autotrackingEnabled && ( />
<CameraFeatureToggle )}
className="p-2 md:p-0" {autotrackingEnabled && (
variant={fullscreen ? "overlay" : "primary"} <CameraFeatureToggle
Icon={autotrackingState == "ON" ? TbViewfinder : TbViewfinderOff} className="p-2 md:p-0"
isActive={autotrackingState == "ON"} variant={fullscreen ? "overlay" : "primary"}
title={`${autotrackingState == "ON" ? "Disable" : "Enable"} Autotracking`} Icon={
onClick={() => autotrackingState == "ON" ? TbViewfinder : TbViewfinderOff
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") }
} isActive={autotrackingState == "ON"}
disabled={!cameraEnabled} title={`${autotrackingState == "ON" ? "Disable" : "Enable"} Autotracking`}
/> onClick={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
}
disabled={!cameraEnabled}
/>
)}
</>
)} )}
<CameraFeatureToggle <CameraFeatureToggle
className={cn( className={cn(
@ -1421,55 +1432,60 @@ function FrigateCameraFeatures({
</DrawerTrigger> </DrawerTrigger>
<DrawerContent className="rounded-2xl px-2 py-4"> <DrawerContent className="rounded-2xl px-2 py-4">
<div className="mt-2 flex flex-col gap-2"> <div className="mt-2 flex flex-col gap-2">
<FilterSwitch {isAdmin && (
label="Camera Enabled" <>
isChecked={enabledState == "ON"} <FilterSwitch
onCheckedChange={() => label="Camera Enabled"
sendEnabled(enabledState == "ON" ? "OFF" : "ON") isChecked={enabledState == "ON"}
} onCheckedChange={() =>
/> sendEnabled(enabledState == "ON" ? "OFF" : "ON")
<FilterSwitch }
label="Object Detection" />
isChecked={detectState == "ON"} <FilterSwitch
onCheckedChange={() => label="Object Detection"
sendDetect(detectState == "ON" ? "OFF" : "ON") isChecked={detectState == "ON"}
} onCheckedChange={() =>
/> sendDetect(detectState == "ON" ? "OFF" : "ON")
{recordingEnabled && ( }
<FilterSwitch />
label="Recording" {recordingEnabled && (
isChecked={recordState == "ON"} <FilterSwitch
onCheckedChange={() => label="Recording"
sendRecord(recordState == "ON" ? "OFF" : "ON") isChecked={recordState == "ON"}
} onCheckedChange={() =>
/> sendRecord(recordState == "ON" ? "OFF" : "ON")
)} }
<FilterSwitch />
label="Snapshots" )}
isChecked={snapshotState == "ON"} <FilterSwitch
onCheckedChange={() => label="Snapshots"
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON") isChecked={snapshotState == "ON"}
} onCheckedChange={() =>
/> sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
{audioDetectEnabled && ( }
<FilterSwitch />
label="Audio Detection" {audioDetectEnabled && (
isChecked={audioState == "ON"} <FilterSwitch
onCheckedChange={() => label="Audio Detection"
sendAudio(audioState == "ON" ? "OFF" : "ON") isChecked={audioState == "ON"}
} onCheckedChange={() =>
/> sendAudio(audioState == "ON" ? "OFF" : "ON")
)} }
{autotrackingEnabled && ( />
<FilterSwitch )}
label="Autotracking" {autotrackingEnabled && (
isChecked={autotrackingState == "ON"} <FilterSwitch
onCheckedChange={() => label="Autotracking"
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") isChecked={autotrackingState == "ON"}
} onCheckedChange={() =>
/> sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
}
/>
)}
</>
)} )}
</div> </div>
<div className="mt-3 flex flex-col gap-5"> <div className="mt-3 flex flex-col gap-5">
{!isRestreamed && ( {!isRestreamed && (
<div className="flex flex-col gap-2 p-2"> <div className="flex flex-col gap-2 p-2">

View File

@ -11,10 +11,25 @@ import axios from "axios";
import CreateUserDialog from "@/components/overlay/CreateUserDialog"; import CreateUserDialog from "@/components/overlay/CreateUserDialog";
import { toast } from "sonner"; import { toast } from "sonner";
import DeleteUserDialog from "@/components/overlay/DeleteUserDialog"; import DeleteUserDialog from "@/components/overlay/DeleteUserDialog";
import { Card } from "@/components/ui/card";
import { HiTrash } from "react-icons/hi"; import { HiTrash } from "react-icons/hi";
import { FaUserEdit } from "react-icons/fa"; import { FaUserEdit } from "react-icons/fa";
import { LuPlus } from "react-icons/lu"; import { LuPlus, LuShield, LuUserCog } from "react-icons/lu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import RoleChangeDialog from "@/components/overlay/RoleChangeDialog";
export default function AuthenticationView() { export default function AuthenticationView() {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -23,8 +38,12 @@ export default function AuthenticationView() {
const [showSetPassword, setShowSetPassword] = useState(false); const [showSetPassword, setShowSetPassword] = useState(false);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [showDelete, setShowDelete] = useState(false); const [showDelete, setShowDelete] = useState(false);
const [showRoleChange, setShowRoleChange] = useState(false);
const [selectedUser, setSelectedUser] = useState<string>(); const [selectedUser, setSelectedUser] = useState<string>();
const [selectedUserRole, setSelectedUserRole] = useState<
"admin" | "viewer"
>();
useEffect(() => { useEffect(() => {
document.title = "Authentication Settings - Frigate"; document.title = "Authentication Settings - Frigate";
@ -32,142 +51,303 @@ export default function AuthenticationView() {
const onSavePassword = useCallback((user: string, password: string) => { const onSavePassword = useCallback((user: string, password: string) => {
axios axios
.put(`users/${user}/password`, { .put(`users/${user}/password`, { password })
password: password,
})
.then((response) => { .then((response) => {
if (response.status == 200) { if (response.status === 200) {
setShowSetPassword(false); setShowSetPassword(false);
toast.success("Password updated successfully", {
position: "top-center",
});
} }
}) })
.catch((_error) => { .catch((error) => {
toast.error("Error setting password", { const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to save password: ${errorMessage}`, {
position: "top-center", position: "top-center",
}); });
}); });
}, []); }, []);
const onCreate = async (user: string, password: string) => { const onCreate = (
try { user: string,
await axios.post("users", { password: string,
username: user, role: "admin" | "viewer",
password: password, ) => {
axios
.post("users", { username: user, password, role })
.then((response) => {
if (response.status === 200 || response.status === 201) {
setShowCreate(false);
mutateUsers((users) => {
users?.push({ username: user, role: role });
return users;
}, false);
toast.success(`User ${user} created successfully`, {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to create user: ${errorMessage}`, {
position: "top-center",
});
}); });
setShowCreate(false);
mutateUsers((users) => {
users?.push({ username: user });
return users;
}, false);
} catch (error) {
toast.error("Error creating user. Check server logs.", {
position: "top-center",
});
}
}; };
const onDelete = async (user: string) => { const onDelete = (user: string) => {
try { axios
await axios.delete(`users/${user}`); .delete(`users/${user}`)
setShowDelete(false); .then((response) => {
mutateUsers((users) => { if (response.status === 200) {
return users?.filter((u) => { setShowDelete(false);
return u.username !== user; mutateUsers(
(users) => users?.filter((u) => u.username !== user),
false,
);
toast.success(`User ${user} deleted successfully`, {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to delete user: ${errorMessage}`, {
position: "top-center",
});
});
};
const onChangeRole = (user: string, newRole: "admin" | "viewer") => {
if (user === "admin") return; // Prevent role change for 'admin'
axios
.put(`users/${user}/role`, { role: newRole })
.then((response) => {
if (response.status === 200) {
setShowRoleChange(false);
mutateUsers(
(users) =>
users?.map((u) =>
u.username === user ? { ...u, role: newRole } : u,
),
false,
);
toast.success(`Role updated for ${user}`, {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to update role: ${errorMessage}`, {
position: "top-center",
}); });
}, false);
} catch (error) {
toast.error("Error deleting user. Check server logs.", {
position: "top-center",
}); });
}
}; };
if (!config || !users) { if (!config || !users) {
return <ActivityIndicator />; return (
<div className="flex h-full w-full items-center justify-center">
<ActivityIndicator />
</div>
);
} }
return ( return (
<div className="flex size-full flex-col md:flex-row"> <div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} /> <Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0"> <div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
<div className="flex flex-row items-center justify-between gap-2"> <div className="mb-5 flex flex-row items-center justify-between gap-2">
<Heading as="h3" className="my-2"> <div className="flex flex-col items-start">
Users <Heading as="h3" className="my-2">
</Heading> User Management
</Heading>
<p className="text-sm text-muted-foreground">
Manage this Frigate instance's user accounts.
</p>
</div>
<Button <Button
className="flex items-center gap-1" className="flex items-center gap-2 self-start sm:self-auto"
aria-label="Add a new user" aria-label="Add a new user"
variant="default" variant="default"
onClick={() => { onClick={() => setShowCreate(true)}
setShowCreate(true);
}}
> >
<LuPlus className="text-secondary-foreground" /> <LuPlus className="size-4" />
Add User Add User
</Button> </Button>
</div> </div>
<div className="mt-3 space-y-3"> <div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
{users.map((u) => ( <div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt">
<Card key={u.username} className="mb-1 p-2"> <div className="h-full overflow-auto">
<div className="flex items-center gap-3"> <Table>
<div className="ml-3 flex flex-none shrink overflow-hidden text-ellipsis align-middle text-lg"> <TableHeader className="sticky top-0 bg-muted/50">
{u.username} <TableRow>
</div> <TableHead className="w-[250px]">Username</TableHead>
<div className="flex flex-1 justify-end space-x-2"> <TableHead>Role</TableHead>
<Button <TableHead className="text-right">Actions</TableHead>
className="flex items-center gap-1" </TableRow>
aria-label="Update the user's password" </TableHeader>
variant="secondary" <TableBody>
onClick={() => { {users.length === 0 ? (
setShowSetPassword(true); <TableRow>
setSelectedUser(u.username); <TableCell colSpan={3} className="h-24 text-center">
}} No users found.
> </TableCell>
<FaUserEdit /> </TableRow>
<div className="hidden md:block">Update Password</div> ) : (
</Button> users.map((user) => (
<Button <TableRow key={user.username} className="group">
className="flex items-center gap-1" <TableCell className="font-medium">
aria-label="Delete the user" <div className="flex items-center gap-2">
variant="destructive" {user.username === "admin" ? (
onClick={() => { <LuShield className="size-4 text-primary" />
setShowDelete(true); ) : (
setSelectedUser(u.username); <LuUserCog className="size-4 text-primary-variant" />
}} )}
> {user.username}
<HiTrash /> </div>
<div className="hidden md:block">Delete</div> </TableCell>
</Button> <TableCell>
</div> <Badge
</div> variant={
</Card> user.role === "admin" ? "default" : "outline"
))} }
className={
user.role === "admin"
? "bg-primary/20 text-primary hover:bg-primary/30"
: ""
}
>
{user.role || "viewer"}
</Badge>
</TableCell>
<TableCell className="text-right">
<TooltipProvider>
<div className="flex items-center justify-end gap-2">
{user.username !== "admin" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 px-2"
onClick={() => {
setSelectedUser(user.username);
setSelectedUserRole(
(user.role as "admin" | "viewer") ||
"viewer",
);
setShowRoleChange(true);
}}
>
<LuUserCog className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
Role
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Change user role</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 px-2"
onClick={() => {
setShowSetPassword(true);
setSelectedUser(user.username);
}}
>
<FaUserEdit className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
Password
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Update password</p>
</TooltipContent>
</Tooltip>
{user.username !== "admin" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="destructive"
className="h-8 px-2"
onClick={() => {
setShowDelete(true);
setSelectedUser(user.username);
}}
>
<HiTrash className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
Delete
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete user</p>
</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div> </div>
</div> </div>
<SetPasswordDialog <SetPasswordDialog
show={showSetPassword} show={showSetPassword}
onCancel={() => { onCancel={() => setShowSetPassword(false)}
setShowSetPassword(false); onSave={(password) => onSavePassword(selectedUser!, password)}
}}
onSave={(password) => {
onSavePassword(selectedUser!, password);
}}
/> />
<DeleteUserDialog <DeleteUserDialog
show={showDelete} show={showDelete}
onCancel={() => { username={selectedUser ?? "this user"}
setShowDelete(false); onCancel={() => setShowDelete(false)}
}} onDelete={() => onDelete(selectedUser!)}
onDelete={() => {
onDelete(selectedUser!);
}}
/> />
<CreateUserDialog <CreateUserDialog
show={showCreate} show={showCreate}
onCreate={onCreate} onCreate={onCreate}
onCancel={() => { onCancel={() => setShowCreate(false)}
setShowCreate(false);
}}
/> />
{selectedUser && selectedUserRole && (
<RoleChangeDialog
show={showRoleChange}
username={selectedUser}
currentRole={selectedUserRole}
onSave={(role) => onChangeRole(selectedUser, role)}
onCancel={() => setShowRoleChange(false)}
/>
)}
</div> </div>
); );
} }

View File

@ -171,10 +171,13 @@ export default function CameraSettingsView({
} }
}) })
.catch((error) => { .catch((error) => {
toast.error( const errorMessage =
`Failed to save config changes: ${error.response.data.message}`, error.response?.data?.message ||
{ position: "top-center" }, error.response?.data?.detail ||
); "Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
position: "top-center",
});
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);

View File

@ -267,10 +267,13 @@ export default function NotificationView({
} }
}) })
.catch((error) => { .catch((error) => {
toast.error( const errorMessage =
`Failed to save config changes: ${error.response.data.message}`, error.response?.data?.message ||
{ position: "top-center" }, error.response?.data?.detail ||
); "Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
position: "top-center",
});
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);

View File

@ -103,10 +103,13 @@ export default function SearchSettingsView({
} }
}) })
.catch((error) => { .catch((error) => {
toast.error( const errorMessage =
`Failed to save config changes: ${error.response.data.message}`, error.response?.data?.message ||
{ position: "top-center" }, error.response?.data?.detail ||
); "Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
position: "top-center",
});
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);

View File

@ -38,10 +38,13 @@ export default function UiSettingsView() {
}); });
}) })
.catch((error) => { .catch((error) => {
toast.error( const errorMessage =
`Failed to clear stored layout: ${error.response.data.message}`, error.response?.data?.message ||
{ position: "top-center" }, error.response?.data?.detail ||
); "Unknown error";
toast.error(`Failed to clear stored layout: ${errorMessage}`, {
position: "top-center",
});
}); });
}); });
}, [config]); }, [config]);
@ -58,10 +61,13 @@ export default function UiSettingsView() {
}); });
}) })
.catch((error) => { .catch((error) => {
toast.error( const errorMessage =
`Failed to clear camera groups streaming settings: ${error.response.data.message}`, error.response?.data?.message ||
{ position: "top-center" }, error.response?.data?.detail ||
); "Unknown error";
toast.error(`Failed to clear streaming settings: ${errorMessage}`, {
position: "top-center",
});
}); });
}, [config]); }, [config]);