This commit is contained in:
ZhaiSoul 2025-03-09 22:29:44 +08:00
commit e72e27e058
51 changed files with 1961 additions and 877 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

@ -283,10 +283,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);
@ -752,9 +755,13 @@ export function CameraGroupEdit({
} }
}) })
.catch((error) => { .catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error( toast.error(
t("toast.save.error", { t("toast.save.error", {
errorMessage: error.response.data.message, errorMessage,
}), }),
{ position: "top-center" }, { position: "top-center" },
); );

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,24 +18,54 @@ 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 { t } from "i18next"; import { t } from "i18next";
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
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>
@ -69,9 +99,22 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
<DropdownMenuLabel> <DropdownMenuLabel>
{t("menu.user.current", { {t("menu.user.current", {
user: profile?.username || t("menu.user.anonymous"), user: profile?.username || t("menu.user.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"
@ -87,6 +130,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

@ -6,7 +6,7 @@ import {
LuList, LuList,
LuLogOut, LuLogOut,
LuMoon, LuMoon,
LuSquare, LuSquarePen,
LuRotateCw, LuRotateCw,
LuSettings, LuSettings,
LuSun, LuSun,
@ -25,7 +25,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 {
@ -34,10 +33,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,
@ -59,10 +56,15 @@ import RestartDialog from "../overlay/dialog/RestartDialog";
import { t } from "i18next"; import { t } from "i18next";
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import { useLanguage } from "@/context/language-provider"; import { useLanguage } from "@/context/language-provider";
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");
@ -73,8 +75,11 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
const { language, setLanguage, systemLanguage } = useLanguage(); const { language, setLanguage, systemLanguage } = useLanguage();
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;
@ -84,6 +89,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}>
@ -128,13 +156,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
@ -148,45 +191,45 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
<span>Logout</span> <span>Logout</span>
</a> </a>
</MenuItem> </MenuItem>
</div>
)}
{isAdmin && (
<>
<DropdownMenuLabel><Trans>menu.system</Trans></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><Trans>menu.systemMetrics</Trans></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><Trans>menu.systemLogs</Trans></span>
</MenuItem>
</Link>
</DropdownMenuGroup>
</> </>
)} )}
<DropdownMenuLabel> <DropdownMenuLabel
<Trans>system</Trans> className={isDesktop && isAdmin ? "mt-3" : "mt-1"}
</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>
<Trans>menu.systemMetrics</Trans>
</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>
<Trans>menu.systemLogs</Trans>
</span>
</MenuItem>
</Link>
</DropdownMenuGroup>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
<Trans>menu.configuration</Trans> <Trans>menu.configuration</Trans>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@ -206,238 +249,143 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
</span> </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"
>
<LuSquare className="mr-2 size-4" />
<span>
<Trans>menu.configurationEditor</Trans>
</span>
</MenuItem>
</Link>
<SubItem>
<SubItemTrigger
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
>
<LuLanguages className="mr-2 size-4" />
<span>
<Trans>menu.languages</Trans>
</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={() => setLanguage("en")}
> >
{language === "en" ? ( <LuSquarePen className="mr-2 size-4" />
<> <span><Trans>menu.configurationEditor</Trans></span>
<LuSun className="mr-2 size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Trans>menu.language.en</Trans>
</>
) : (
<span className="ml-6 mr-2">
<Trans>menu.language.en</Trans>
</span>
)}
</MenuItem> </MenuItem>
<MenuItem </Link>
className={ </>
isDesktop )}
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label="Dark mode"
onClick={() => setLanguage("zh-CN")}
>
{language === "zh-CN" ? (
<>
<LuMoon className="mr-2 size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<Trans>menu.language.zhCN</Trans>
</>
) : (
<span className="ml-6 mr-2">
<Trans>menu.language.zhCN</Trans>
</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={() => setLanguage(systemLanguage)}
>
{language === systemLanguage ? (
<>
<CgDarkMode className="mr-2 size-4 scale-100 transition-all" />
<Trans>menu.withSystem</Trans>
</>
) : (
<span className="ml-6 mr-2">
<Trans>menu.withSystem</Trans>
</span>
)}
</MenuItem>
</SubItemContent>
</Portal>
</SubItem>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
<Trans>menu.appearance</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<SubItem>
<SubItemTrigger
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
>
<LuSunMoon className="mr-2 size-4" />
<span>
<Trans>menu.darkMode.label</Trans>
</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" />
<Trans>menu.darkMode.light</Trans>
</>
) : (
<span className="ml-6 mr-2">
<Trans>menu.darkMode.light</Trans>
</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" />
<Trans>menu.darkMode.dark</Trans>
</>
) : (
<span className="ml-6 mr-2">
<Trans>menu.darkMode.dark</Trans>
</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" />
<Trans>menu.withSystem</Trans>
</>
) : (
<span className="ml-6 mr-2">
<Trans>menu.withSystem</Trans>
</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>
<Trans>menu.theme.label</Trans>
</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" />
<Trans>{friendlyColorSchemeName(scheme)}</Trans>
</>
) : (
<span className="ml-6 mr-2">
<Trans>{friendlyColorSchemeName(scheme)}</Trans>
</span>
)}
</MenuItem>
))}
</SubItemContent>
</Portal>
</SubItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
<Trans>menu.appearance</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<SubItem>
<SubItemTrigger
className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
>
<LuSunMoon className="mr-2 size-4" />
<span><Trans>menu.darkMode.label</Trans></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" />
<Trans>menu.darkMode.light</Trans>
</>
) : (
<span className="ml-6 mr-2"><Trans>menu.darkMode.light</Trans></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" />
<Trans>menu.darkMode.dark</Trans>
</>
) : (
<span className="ml-6 mr-2"><Trans>menu.darkMode.dark</Trans></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" />
<Trans>menu.withSystem</Trans>
</>
) : (
<span className="ml-6 mr-2"><Trans>menu.withSystem</Trans></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><Trans>menu.theme.label</Trans></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" />
<Trans>{friendlyColorSchemeName(scheme)}</Trans>
</>
) : (
<span className="ml-6 mr-2">
<Trans>{friendlyColorSchemeName(scheme)}</Trans>
</span>
)}
</MenuItem>
))}
</SubItemContent>
</Portal>
</SubItem>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}> <DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
<Trans>menu.help</Trans> <Trans>menu.help</Trans>
</DropdownMenuLabel> </DropdownMenuLabel>
@ -469,19 +417,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={t("restart")} <MenuItem
onClick={() => setRestartDialogOpen(true)} className={
> isDesktop
<LuRotateCw className="mr-2 size-4" /> ? "cursor-pointer"
<span> : "flex items-center p-2 text-sm"
<Trans>menu.restart</Trans> }
</span> aria-label="Restart Frigate"
</MenuItem> onClick={() => setRestartDialogOpen(true)}
>
<LuRotateCw className="mr-2 size-4" />
<span><Trans>menu.restart</Trans></span>
</MenuItem>
</>
)}
</div> </div>
</Content> </Content>
</Container> </Container>
@ -490,6 +444,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

@ -76,8 +76,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,22 +13,33 @@ 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 { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import { t } from "i18next"; import { t } from "i18next";
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,
@ -35,15 +47,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: t("users.dialog.createUser.usernameOnlyInclude", {ns: "views/settings"}), .regex(/^[A-Za-z0-9._]+$/, {
}), message: t("users.dialog.createUser.usernameOnlyInclude", {ns: "views/settings"}),
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),
@ -51,36 +70,92 @@ 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> <DialogTitle><Trans ns="views/settings">users.dialog.createUser.title</Trans></DialogTitle>
<Trans ns="views/settings">users.dialog.createUser.title</Trans> <DialogDescription>
</DialogTitle> <Trans ns="views/settings">users.dialog.createUser.desc</Trans>
</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> <FormLabel className="text-sm font-medium">
<Trans ns="views/settings">users.dialog.createUser.user</Trans> <Trans ns="views/settings">users.dialog.createUser.user</Trans>
</FormLabel> </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">
<Trans ns="views/settings">users.dialog.createUser.user.desc</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
<Trans ns="views/settings">users.dialog.createUser.password</Trans>
</FormLabel>
<FormControl>
<Input
placeholder="Enter password"
type="password"
className="h-10"
{...field} {...field}
/> />
</FormControl> </FormControl>
@ -88,34 +163,119 @@ export default function CreateUserDialog({
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
name="password" name="confirmPassword"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel className="text-sm font-medium">
<Trans ns="views/settings"> <Trans ns="views/settings">users.dialog.createUser.confirmPassword</Trans>
users.dialog.createUser.password
</Trans>
</FormLabel> </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">
<Trans ns="views/settings">users.dialog.createUser.password.match</Trans>
</span>
</>
) : (
<>
<LuX className="size-3.5 text-red-500" />
<span className="text-red-600">
<Trans ns="views/settings">users.dialog.createUser.password.notMatch</Trans>
</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"><Trans>role.title</Trans></FormLabel>
{isLoading && <ActivityIndicator className="mr-2 h-4 w-4" />} <Select
<Trans ns="views/settings">users.dialog.createUser.title</Trans> 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><Trans>role.admin</Trans></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><Trans>role.viewer</Trans></span>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormDescription className="text-xs text-muted-foreground">
<Trans>role.desc</Trans>
</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><Trans>button.saving</Trans></span>
</div>
) : (
<Trans>button.save</Trans>
)}
</Button>
</div>
</div>
</DialogFooter> </DialogFooter>
</form> </form>
</Form> </Form>

View File

@ -7,38 +7,59 @@ 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> <div className="space-y-1 text-center sm:text-left">
<Trans ns="views/settings">users.dialog.deleteUser</Trans> <Trans ns="views/settings">users.dialog.deleteUser.title</Trans>
</DialogTitle> <DialogDescription>
<Trans ns="views/settings">users.dialog.deleteUser.desc</Trans>
</DialogDescription>
</div>
</DialogHeader> </DialogHeader>
<div>
<Trans ns="views/settings">users.dialog.deleteUser.warn</Trans> <div className="my-4 rounded-md border border-destructive/20 bg-destructive/5 p-4 text-center text-sm">
<p className="font-medium text-destructive">
<Trans ns="views/settings" values={{username}}>users.dialog.deleteUser.warn</Trans>
</p>
</div> </div>
<DialogFooter>
<Button <DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
className="flex items-center gap-1" <div className="flex flex-1 flex-col justify-end">
aria-label="Confirm delete" <div className="flex flex-row gap-2 pt-5">
variant="destructive" <Button
size="sm" className="flex flex-1"
onClick={onDelete} aria-label="Cancel"
> onClick={onCancel}
<Trans>button.delete</Trans> type="button"
</Button> >
<Trans>button.cancel</Trans>
</Button>
<Button
variant="destructive"
aria-label="Delete"
className="flex flex-1"
onClick={onDelete}
>
<Trans>button.delete</Trans>
</Button>
</div>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -102,20 +102,14 @@ export default function ExportDialog({
} }
}) })
.catch((error) => { .catch((error) => {
if (error.response?.data?.message) { const errorMessage =
// api error message need to be translated error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error( toast.error(
`${t("export.toast.error.failed", { error: error.response.data.message, ns: "components/dialog" })}`, t("export.toast.error.failed", { error: errorMessage, ns: "components/dialog" }),
{ position: "top-center" }, { position: "top-center" },
); );
} else {
toast.error(
`${t("export.toast.error.failed", { error: error.message, ns: "components/dialog" })}`,
{
position: "top-center",
},
);
}
}); });
}, [camera, name, range, setRange, setName, setMode]); }, [camera, name, range, setRange, setName, setMode]);

View File

@ -107,16 +107,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,53 +1,204 @@
"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 { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import { Label } from "../ui/label";
import { LuCheck, LuX } from "react-icons/lu";
import { t } from "i18next";
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> <DialogTitle>
<Trans ns="views/settings">users.dialog.setPassword.title</Trans> {username ? t("users.dialog.passwordSetting.updatePassword", {username, ns: "views/settings"}) : t("users.dialog.passwordSetting.setPassword", {ns: "views/settings"})}
</DialogTitle> </DialogTitle>
<DialogDescription>
<Trans ns="views/settings">users.dialog.passwordSetting.desc</Trans>
</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"><Trans ns="views/settings">users.dialog.form.newPassword</Trans></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={t("users.dialog.form.newPassword.placeholder", {ns: "views/settings"})}
}} autoFocus
> />
<Trans>button.save</Trans>
</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"><Trans ns="views/settings">users.dialog.form.password.confirm</Trans></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"><Trans ns="views/settings">users.dialog.form.password.match</Trans></span>
</>
) : (
<>
<LuX className="size-3.5 text-red-500" />
<span className="text-red-600"><Trans ns="views/settings">users.dialog.form.password.notMatch</Trans></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"
>
<Trans>button.cancel</Trans>
</Button>
<Button
variant="select"
aria-label="Save"
className="flex flex-1"
onClick={handleSave}
disabled={!password || password !== confirmPassword}
>
<Trans>button.save</Trans>
</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

@ -402,8 +402,12 @@ function ObjectDetailsTab({
}, },
); );
}) })
.catch(() => { .catch((error) => {
toast.error(t("details.tips.saveDescriptionFailed", {ns: "views/explore"}), { const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("details.tips.saveDescriptionFailed", {ns: "views/explore", errorMessage}), {
position: "top-center", position: "top-center",
}); });
setDesc(search.data.description); setDesc(search.data.description);
@ -430,11 +434,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",
},
); );
}); });
}, },
@ -500,8 +506,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

@ -191,10 +191,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

@ -228,12 +228,15 @@ export default function ObjectMaskEditPane({
} }
}) })
.catch((error) => { .catch((error) => {
toast.error( const errorMessage =
t("toast.save.error", { error.response?.data?.message ||
errorMessage: error.response.data.message, error.response?.data?.detail ||
}), "Unknown error";
{ position: "top-center" }, toast.error(t("toast.save.error", {
); errorMessage
}), {
position: "top-center",
});
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);

View File

@ -187,10 +187,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

@ -448,12 +448,15 @@ export default function ZoneEditPane({
} }
}) })
.catch((error) => { .catch((error) => {
toast.error( const errorMessage =
t("toast.save.error", { error.response?.data?.message ||
errorMessage: error.response.data.message, error.response?.data?.detail ||
}), "Unknown error";
{ position: "top-center" }, toast.error(t("toast.save.error", {
); 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

@ -7,6 +7,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { StatusBarMessagesProvider } from "@/context/statusbar-provider"; import { StatusBarMessagesProvider } from "@/context/statusbar-provider";
import { LanguageProvider } from "./language-provider"; import { LanguageProvider } from "./language-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;
@ -15,21 +16,23 @@ type TProvidersProps = {
function providers({ children }: TProvidersProps) { function providers({ children }: TProvidersProps) {
return ( return (
<RecoilRoot> <RecoilRoot>
<ApiProvider> <AuthProvider>
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme"> <ApiProvider>
<LanguageProvider> <ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
<TooltipProvider> <LanguageProvider>
<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>
</LanguageProvider> </TooltipProvider>
</ThemeProvider> </LanguageProvider>
</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

@ -60,11 +60,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

@ -95,16 +95,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

@ -20,7 +20,7 @@ import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useOptimisticState from "@/hooks/use-optimistic-state"; import useOptimisticState from "@/hooks/use-optimistic-state";
import { isMobile } from "react-device-detect"; import { isMobile, isMobileSafari } from "react-device-detect";
import { FaVideo } from "react-icons/fa"; import { FaVideo } from "react-icons/fa";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr"; import useSWR from "swr";
@ -41,6 +41,9 @@ import { t } from "i18next";
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 { isInIframe } from "@/utils/isIFrame";
import { isPWA } from "@/utils/isPWA";
import { useIsAdmin } from "@/hooks/use-is-admin";
const allSettingsViews = [ const allSettingsViews = [
"uiSettings", "uiSettings",
@ -63,6 +66,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);
@ -141,7 +153,7 @@ export default function Settings() {
); );
if (element instanceof HTMLElement) { if (element instanceof HTMLElement) {
scrollIntoView(element, { scrollIntoView(element, {
behavior: "smooth", behavior: isMobileSafari && !isPWA && isInIframe ? "auto" : "smooth",
inline: "start", inline: "start",
}); });
} }
@ -150,7 +162,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");
@ -181,11 +198,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 == "uiSettings" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`} className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "uiSettings" ? "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

@ -0,0 +1,8 @@
export const isInIframe = (() => {
try {
return window.self !== window.top;
} catch (e) {
// If we get a security error, we're definitely in an iframe
return true;
}
})();

View File

@ -205,16 +205,13 @@ export default function EventView({
} }
}) })
.catch((error) => { .catch((error) => {
if (error.response?.data?.message) { const errorMessage =
toast.error( error.response?.data?.message ||
t("export.toast.error", { ns: "components/dialog", message: error.response.data.message }), error.response?.data?.detail ||
{ position: "top-center" }, "Unknown error";
); toast.error(t("export.toast.error", { ns: "components/dialog", message: errorMessage }), {
} else { position: "top-center",
toast.error(t("export.toast.error", { ns: "components/dialog", message: error.message }), { });
position: "top-center",
});
}
}); });
}, },
[reviewItems], [reviewItems],

View File

@ -118,6 +118,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;
@ -994,6 +995,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);
@ -1091,85 +1096,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={ Icon={detectState == "ON" ? MdPersonSearch : MdPersonOff}
detectState == "ON" isActive={detectState == "ON"}
? t("detect.disable", { ns: "views/live"}) title={`${detectState == "ON" ? "Disable" : "Enable"} Detect`}
: t("detect.enable", { ns: "views/live"}) onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
} disabled={!cameraEnabled}
onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")} />
disabled={!cameraEnabled} <CameraFeatureToggle
/> className="p-2 md:p-0"
<CameraFeatureToggle variant={fullscreen ? "overlay" : "primary"}
className="p-2 md:p-0" Icon={recordState == "ON" ? LuVideo : LuVideoOff}
variant={fullscreen ? "overlay" : "primary"} isActive={recordState == "ON"}
Icon={recordState == "ON" ? LuVideo : LuVideoOff} title={`${recordState == "ON" ? "Disable" : "Enable"} Recording`}
isActive={recordState == "ON"} onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
title={ disabled={!cameraEnabled}
recordState == "ON" />
? t("recording.disable", { ns: "views/live"}) <CameraFeatureToggle
: t("recording.enable", { ns: "views/live"}) className="p-2 md:p-0"
} variant={fullscreen ? "overlay" : "primary"}
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")} Icon={snapshotState == "ON" ? MdPhotoCamera : MdNoPhotography}
disabled={!cameraEnabled} isActive={snapshotState == "ON"}
/> title={`${snapshotState == "ON" ? "Disable" : "Enable"} Snapshots`}
<CameraFeatureToggle onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")}
className="p-2 md:p-0" disabled={!cameraEnabled}
variant={fullscreen ? "overlay" : "primary"} />
Icon={snapshotState == "ON" ? MdPhotoCamera : MdNoPhotography} {audioDetectEnabled && (
isActive={snapshotState == "ON"} <CameraFeatureToggle
title={ className="p-2 md:p-0"
snapshotState == "ON" variant={fullscreen ? "overlay" : "primary"}
? t("snapshots.disable", { ns: "views/live"}) Icon={audioState == "ON" ? LuEar : LuEarOff}
: t("snapshots.enable", { ns: "views/live"}) isActive={audioState == "ON"}
} title={`${audioState == "ON" ? "Disable" : "Enable"} Audio Detect`}
onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")} onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled} disabled={!cameraEnabled}
/> />
{audioDetectEnabled && ( )}
<CameraFeatureToggle {autotrackingEnabled && (
className="p-2 md:p-0" <CameraFeatureToggle
variant={fullscreen ? "overlay" : "primary"} className="p-2 md:p-0"
Icon={audioState == "ON" ? LuEar : LuEarOff} variant={fullscreen ? "overlay" : "primary"}
isActive={audioState == "ON"} Icon={
title={ autotrackingState == "ON" ? TbViewfinder : TbViewfinderOff
audioState == "ON" }
? t("audioDetect.disable", { ns: "views/live"}) isActive={autotrackingState == "ON"}
: t("audioDetect.enable", { ns: "views/live"}) title={`${autotrackingState == "ON" ? "Disable" : "Enable"} Autotracking`}
} onClick={() =>
onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")} sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
disabled={!cameraEnabled} }
/> disabled={!cameraEnabled}
)} />
{autotrackingEnabled && ( )}
<CameraFeatureToggle </>
className="p-2 md:p-0"
variant={fullscreen ? "overlay" : "primary"}
Icon={autotrackingState == "ON" ? TbViewfinder : TbViewfinderOff}
isActive={autotrackingState == "ON"}
title={
autotrackingState == "ON"
? t("autotracking.disable", { ns: "views/live"})
: t("autotracking.enable", { ns: "views/live"})
}
onClick={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
}
disabled={!cameraEnabled}
/>
)} )}
<CameraFeatureToggle <CameraFeatureToggle
className={cn( className={cn(
@ -1458,55 +1449,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,12 +11,27 @@ 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 { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import { t } from "i18next"; import { t } from "i18next";
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");
@ -25,8 +40,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";
@ -34,146 +53,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(t("users.toast.error.setPasswordFailed", {ns: "views/settings"}), { const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("users.toast.error.setPasswordFailed", {ns: "views/settings", 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(t("users.toast.success.createUser", {ns: "views/settings", user}), {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("users.toast.error.createUserFailed", {ns: "views/settings", errorMessage}), {
position: "top-center",
});
}); });
setShowCreate(false);
mutateUsers((users) => {
users?.push({ username: user });
return users;
}, false);
} catch (error) {
toast.error(t("users.toast.error.createUserFailed", {ns: "views/settings"}), {
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(t("users.toast.success.deleteUser", {ns: "views/settings", user}), {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("users.toast.error.deleteUserFailed", {ns: "views/settings", 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(t("users.toast.error.deleteUserFailed", {ns: "views/settings"}), {
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">
<Trans ns="views/settings">users.title</Trans> <Heading as="h3" className="my-2">
</Heading> <Trans ns="views/settings">users.management</Trans>
</Heading>
<p className="text-sm text-muted-foreground">
<Trans ns="views/settings">users.management.desc</Trans>
</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" />
<Trans ns="views/settings">users.addUser</Trans> 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]"><Trans ns="views/settings">users.table.username</Trans></TableHead>
<div className="flex flex-1 justify-end space-x-2"> <TableHead>Role</TableHead>
<Button <TableHead className="text-right"><Trans ns="views/settings">users.table.actions</Trans></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">
}} <Trans ns="views/settings">users.table.noUsers</Trans>
> </TableCell>
<FaUserEdit /> </TableRow>
<div className="hidden md:block"> ) : (
<Trans ns="views/settings">users.updatePassword</Trans> users.map((user) => (
</div> <TableRow key={user.username} className="group">
</Button> <TableCell className="font-medium">
<Button <div className="flex items-center gap-2">
className="flex items-center gap-1" {user.username === "admin" ? (
aria-label="Delete the user" <LuShield className="size-4 text-primary" />
variant="destructive" ) : (
onClick={() => { <LuUserCog className="size-4 text-primary-variant" />
setShowDelete(true); )}
setSelectedUser(u.username); {user.username}
}} </div>
> </TableCell>
<HiTrash /> <TableCell>
<div className="hidden md:block"> <Badge
<Trans>button.delete</Trans> variant={
</div> user.role === "admin" ? "default" : "outline"
</Button> }
</div> className={
</div> user.role === "admin"
</Card> ? "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">
<Trans>role.title</Trans>
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p><Trans ns="views/settings">users.table.changeRole</Trans></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">
<Trans ns="views/settings">users.table.password</Trans>
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p><Trans ns="views/settings">users.updatePassword</Trans></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">
<Trans>button.delete</Trans>
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p><Trans ns="views/settings">users.table.deleteUser</Trans></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

@ -178,12 +178,15 @@ export default function CameraSettingsView({
} }
}) })
.catch((error) => { .catch((error) => {
toast.error( const errorMessage =
t("toast.save.error", { error.response?.data?.message ||
errorMessage: error.response.data.message, error.response?.data?.detail ||
}), "Unknown error";
{ position: "top-center" }, toast.error(t("toast.save.error", {
); errorMessage
}), {
position: "top-center",
});
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);

View File

@ -269,10 +269,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

@ -109,12 +109,15 @@ export default function ExploreSettingsView({
} }
}) })
.catch((error) => { .catch((error) => {
toast.error( const errorMessage =
t("toast.save.error", { error.response?.data?.message ||
errorMessage: error.response.data.message, error.response?.data?.detail ||
}), "Unknown error";
{ position: "top-center" }, toast.error(t("toast.save.error", {
); errorMessage
}), {
position: "top-center",
});
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);

View File

@ -40,10 +40,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]);
@ -60,10 +63,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]);