From 74ca009b0b767a8393562a932673f8fd5b11b7fe Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 8 Mar 2025 10:01:08 -0600 Subject: [PATCH] UI viewer role (#16978) * db migration * db model * assign admin role on password reset * add role to jwt and api responses * don't restrict api access for admins yet * use json response * frontend auth context * update auth form for profile endpoint * add access denied page * add protected routes * auth hook * dialogs * user settings view * restrict viewer access to settings * restrict camera functions for viewer role * add password dialog to account menu * spacing tweak * migrator default to admin * escape quotes in migrator * ui tweaks * tweaks * colors * colors * fix merge conflict * fix icons * add api layer enforcement * ui tweaks * fix error message * debug * clean up * remove print * guard apis for admin only * fix tests * fix review tests * use correct error responses from api in toasts * add role to account menu --- .../usr/local/nginx/conf/auth_request.conf | 4 +- frigate/api/app.py | 7 +- frigate/api/auth.py | 177 ++++++-- frigate/api/classification.py | 11 +- frigate/api/defs/request/app_body.py | 7 + frigate/api/event.py | 55 ++- frigate/api/export.py | 9 +- frigate/api/review.py | 7 +- frigate/app.py | 1 + frigate/config/proxy.py | 4 + frigate/models.py | 4 + frigate/test/http_api/test_http_review.py | 18 +- frigate/test/test_http.py | 9 +- migrations/029_add_user_role.py | 37 ++ web/src/App.tsx | 83 ++-- web/src/components/auth/AuthForm.tsx | 20 +- web/src/components/auth/ProtectedRoute.tsx | 40 ++ .../components/filter/CameraGroupSelector.tsx | 22 +- .../components/filter/SearchActionGroup.tsx | 8 +- web/src/components/menu/AccountSettings.tsx | 53 ++- web/src/components/menu/GeneralSettings.tsx | 417 ++++++++++-------- .../components/menu/SearchResultActions.tsx | 8 +- .../components/overlay/CreateUserDialog.tsx | 229 ++++++++-- .../components/overlay/DeleteUserDialog.tsx | 59 ++- web/src/components/overlay/ExportDialog.tsx | 17 +- .../overlay/MobileReviewSettingsDrawer.tsx | 17 +- .../components/overlay/RoleChangeDialog.tsx | 119 +++++ .../components/overlay/SetPasswordDialog.tsx | 198 ++++++++- .../overlay/detail/AnnotationSettingsPane.tsx | 11 +- .../overlay/detail/SearchDetailDialog.tsx | 26 +- .../settings/MotionMaskEditPane.tsx | 11 +- .../settings/ObjectMaskEditPane.tsx | 11 +- web/src/components/settings/PolygonItem.tsx | 11 +- web/src/components/settings/ZoneEditPane.tsx | 11 +- web/src/context/auth-context.tsx | 74 ++++ web/src/context/providers.tsx | 29 +- web/src/hooks/use-is-admin.ts | 10 + web/src/pages/AccessDenied.tsx | 21 + web/src/pages/ConfigEditor.tsx | 11 +- web/src/pages/Exports.tsx | 17 +- web/src/pages/FaceLibrary.tsx | 101 ++--- web/src/pages/Settings.tsx | 26 +- web/src/types/user.ts | 1 + web/src/views/events/EventView.tsx | 17 +- web/src/views/live/LiveCameraView.tsx | 228 +++++----- web/src/views/settings/AuthenticationView.tsx | 372 ++++++++++++---- web/src/views/settings/CameraSettingsView.tsx | 11 +- .../settings/NotificationsSettingsView.tsx | 11 +- web/src/views/settings/SearchSettingsView.tsx | 11 +- web/src/views/settings/UiSettingsView.tsx | 22 +- 50 files changed, 1951 insertions(+), 732 deletions(-) create mode 100644 migrations/029_add_user_role.py create mode 100644 web/src/components/auth/ProtectedRoute.tsx create mode 100644 web/src/components/overlay/RoleChangeDialog.tsx create mode 100644 web/src/context/auth-context.tsx create mode 100644 web/src/hooks/use-is-admin.ts create mode 100644 web/src/pages/AccessDenied.tsx diff --git a/docker/main/rootfs/usr/local/nginx/conf/auth_request.conf b/docker/main/rootfs/usr/local/nginx/conf/auth_request.conf index b054a6b97..9e745b6dc 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/auth_request.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/auth_request.conf @@ -1,14 +1,16 @@ ## Send a subrequest to verify if the user is authenticated and has permission to access the resource. 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 $role $upstream_http_remote_role; auth_request_set $groups $upstream_http_remote_groups; auth_request_set $name $upstream_http_remote_name; auth_request_set $email $upstream_http_remote_email; ## 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-Role $role; proxy_set_header Remote-Groups $groups; proxy_set_header Remote-Email $email; proxy_set_header Remote-Name $name; diff --git a/frigate/api/app.py b/frigate/api/app.py index c55e36a4b..5ce90130f 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -22,6 +22,7 @@ from markupsafe import escape from peewee import operator 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.request.app_body import AppConfigSetBody 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")): new_config = body.decode() 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): 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(): try: restart_frigate() diff --git a/frigate/api/auth.py b/frigate/api/auth.py index be5917450..1752b19c9 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -11,8 +11,9 @@ import secrets import time from datetime import datetime 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 joserfc import jwt from peewee import DoesNotExist @@ -22,6 +23,7 @@ from frigate.api.defs.request.app_body import ( AppPostLoginBody, AppPostUsersBody, AppPutPasswordBody, + AppPutRoleBody, ) from frigate.api.defs.tags import Tags from frigate.config import AuthConfig, ProxyConfig @@ -169,8 +171,10 @@ def verify_password(password, password_hash): return secrets.compare_digest(password_hash, compare_hash) -def create_encoded_jwt(user, expiration, secret): - return jwt.encode({"alg": "HS256"}, {"sub": user, "exp": expiration}, secret) +def create_encoded_jwt(user, role, 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): @@ -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") def auth(request: Request): 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 # 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: + success_response.headers["remote-user"] = "anonymous" + success_response.headers["remote-role"] = "admin" return success_response fail_response = Response("", status_code=401) @@ -211,14 +258,18 @@ def auth(request: Request): if not auth_config.enabled: # pass the user header value from the upstream proxy if a mapping is specified # or use anonymous if none are specified - if proxy_config.header_map.user is not None: - upstream_user_header_value = request.headers.get( - proxy_config.header_map.user, - default="anonymous", - ) - success_response.headers["remote-user"] = upstream_user_header_value - else: - success_response.headers["remote-user"] = "anonymous" + user_header = proxy_config.header_map.user + role_header = proxy_config.header_map.get("role", "Remote-Role") + success_response.headers["remote-user"] = ( + request.headers.get(user_header, default="anonymous") + if user_header + else "anonymous" + ) + success_response.headers["remote-role"] = ( + request.headers.get(role_header, default="viewer") + if role_header + else "viewer" + ) return success_response # now apply authentication @@ -251,11 +302,15 @@ def auth(request: Request): if "sub" not in token.claims: logger.debug("user not set in jwt token") 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: logger.debug("exp not set in jwt token") return fail_response user = token.claims.get("sub") + role = token.claims.get("role") current_time = int(time.time()) # if the jwt is expired @@ -283,7 +338,7 @@ def auth(request: Request): return fail_response new_expiration = current_time + JWT_SESSION_LENGTH new_encoded_jwt = create_encoded_jwt( - user, new_expiration, request.app.jwt_token + user, role, new_expiration, request.app.jwt_token ) set_jwt_cookie( success_response, @@ -294,6 +349,7 @@ def auth(request: Request): ) success_response.headers["remote-user"] = user + success_response.headers["remote-role"] = role return success_response except Exception as e: logger.error(f"Error parsing jwt: {e}") @@ -302,8 +358,16 @@ def auth(request: Request): @router.get("/profile") def profile(request: Request): - username = request.headers.get("remote-user") - return JSONResponse(content={"username": username}) + username = request.headers.get("remote-user", "anonymous") + 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") @@ -333,8 +397,11 @@ def login(request: Request, body: AppPostLoginBody): password_hash = db_user.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 - 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) set_jwt_cookie( 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) -@router.get("/users") +@router.get("/users", dependencies=[Depends(require_role(["admin"]))]) 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]) -@router.post("/users") -def create_user(request: Request, body: AppPostUsersBody): +@router.post("/users", dependencies=[Depends(require_role(["admin"]))]) +def create_user( + request: Request, + body: AppPostUsersBody, +): HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations 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) - User.insert( { User.username: body.username, User.password_hash: password_hash, + User.role: role, User.notification_tokens: [], } ).execute() @@ -375,15 +448,61 @@ def delete_user(username: str): @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 password_hash = hash_password(body.password, iterations=HASH_ITERATIONS) + User.set_by_id(username, {User.password_hash: password_hash}) - User.set_by_id( - username, - { - User.password_hash: password_hash, - }, - ) + return JSONResponse(content={"success": True}) + + +@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}) diff --git a/frigate/api/classification.py b/frigate/api/classification.py index bd395737a..85b604379 100644 --- a/frigate/api/classification.py +++ b/frigate/api/classification.py @@ -6,12 +6,13 @@ import random import shutil import string -from fastapi import APIRouter, Request, UploadFile +from fastapi import APIRouter, Depends, Request, UploadFile from fastapi.responses import JSONResponse from pathvalidate import sanitize_filename from peewee import DoesNotExist from playhouse.shortcuts import model_to_dict +from frigate.api.auth import require_role from frigate.api.defs.tags import Tags from frigate.const import FACE_DIR from frigate.embeddings import EmbeddingsContext @@ -44,7 +45,7 @@ def get_faces(): 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): if not request.app.frigate_config.face_recognition.enabled: 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): if not request.app.frigate_config.face_recognition.enabled: 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): if not request.app.frigate_config.face_recognition.enabled: 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): if not request.app.frigate_config.face_recognition.enabled: return JSONResponse( diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py index 85daa5631..1fc05db2f 100644 --- a/frigate/api/defs/request/app_body.py +++ b/frigate/api/defs/request/app_body.py @@ -1,3 +1,5 @@ +from typing import Optional + from pydantic import BaseModel @@ -12,8 +14,13 @@ class AppPutPasswordBody(BaseModel): class AppPostUsersBody(BaseModel): username: str password: str + role: Optional[str] = "viewer" class AppPostLoginBody(BaseModel): user: str password: str + + +class AppPutRoleBody(BaseModel): + role: str diff --git a/frigate/api/event.py b/frigate/api/event.py index 9a5578bae..100bdfd9e 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -14,6 +14,7 @@ from fastapi.responses import JSONResponse from peewee import JOIN, DoesNotExist, fn, operator from playhouse.shortcuts import model_to_dict +from frigate.api.auth import require_role from frigate.api.defs.query.events_query_parameters import ( DEFAULT_TIME_RANGE, EventsQueryParams, @@ -708,7 +709,11 @@ def event(event_id: str): 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): try: 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): try: 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( request: Request, 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( request: Request, 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( 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"} -@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): result = delete_single_event(event_id, request) status_code = 200 if result["success"] else 404 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): if not body.event_ids: return JSONResponse( @@ -1170,7 +1199,11 @@ def delete_events(request: Request, body: EventsDeleteBody): 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( request: Request, 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): try: end_time = body.end_time or datetime.datetime.now().timestamp() diff --git a/frigate/api/export.py b/frigate/api/export.py index 2ccbc4beb..160434c68 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -6,11 +6,12 @@ import string from pathlib import Path import psutil -from fastapi import APIRouter, Request +from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse from peewee import DoesNotExist 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_rename_body import ExportRenameBody 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): try: 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): try: export: Export = Export.get(Export.id == event_id) diff --git a/frigate/api/review.py b/frigate/api/review.py index 3e503d400..4788356f3 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -12,6 +12,7 @@ from fastapi.responses import JSONResponse from peewee import Case, DoesNotExist, fn, operator from playhouse.shortcuts import model_to_dict +from frigate.api.auth import require_role from frigate.api.defs.query.review_query_parameters import ( ReviewActivityMotionQueryParams, 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): list_of_ids = body.ids reviews = ( diff --git a/frigate/app.py b/frigate/app.py index 8b63ab0a0..cdb4877cc 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -620,6 +620,7 @@ class FrigateApp: ) User.replace( username="admin", + role="admin", password_hash=password_hash, notification_tokens=[], ).execute() diff --git a/frigate/config/proxy.py b/frigate/config/proxy.py index 3427f60a0..df8a665fb 100644 --- a/frigate/config/proxy.py +++ b/frigate/config/proxy.py @@ -12,6 +12,10 @@ class HeaderMappingConfig(FrigateBaseModel): user: str = Field( 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): diff --git a/frigate/models.py b/frigate/models.py index 62bbf0bd3..26375432e 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -117,5 +117,9 @@ class RecordingsToDelete(Model): # type: ignore[misc] class User(Model): # type: ignore[misc] 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) notification_tokens = JSONField() diff --git a/frigate/test/http_api/test_http_review.py b/frigate/test/http_api/test_http_review.py index c8f2b1719..ee7d96bc5 100644 --- a/frigate/test/http_api/test_http_review.py +++ b/frigate/test/http_api/test_http_review.py @@ -504,7 +504,7 @@ class TestHttpReview(BaseTestHttp): def test_post_reviews_delete_no_body(self): with TestClient(self.app) as client: super().insert_mock_review_segment("123456.random") - response = client.post("/reviews/delete") + response = client.post("/reviews/delete", headers={"remote-role": "admin"}) # Missing ids assert response.status_code == 422 @@ -512,7 +512,9 @@ class TestHttpReview(BaseTestHttp): with TestClient(self.app) as client: super().insert_mock_review_segment("123456.random") body = {"ids": [""]} - response = client.post("/reviews/delete", json=body) + response = client.post( + "/reviews/delete", json=body, headers={"remote-role": "admin"} + ) # Missing ids assert response.status_code == 422 @@ -521,7 +523,9 @@ class TestHttpReview(BaseTestHttp): id = "123456.random" super().insert_mock_review_segment(id) 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 response_json = response.json() assert response_json["success"] == True @@ -536,7 +540,9 @@ class TestHttpReview(BaseTestHttp): id = "123456.random" super().insert_mock_review_segment(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 response_json = response.json() assert response_json["success"] == True @@ -558,7 +564,9 @@ class TestHttpReview(BaseTestHttp): assert len(recordings_ids_in_db_before) == 2 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 response_json = response.json() assert response_json["success"] == True diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py index 0238c766c..d6ff91a83 100644 --- a/frigate/test/test_http.py +++ b/frigate/test/test_http.py @@ -172,7 +172,7 @@ class TestHttp(unittest.TestCase): event = client.get(f"/events/{id}").json() assert event 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() assert event == "Event not found" @@ -192,12 +192,12 @@ class TestHttp(unittest.TestCase): with TestClient(app) as client: _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() assert event assert event["id"] == id 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() assert event assert event["id"] == id @@ -262,6 +262,7 @@ class TestHttp(unittest.TestCase): new_sub_label_response = client.post( f"/events/{id}/sub_label", json={"subLabel": sub_label}, + headers={"remote-role": "admin"}, ) assert new_sub_label_response.status_code == 200 event = client.get(f"/events/{id}").json() @@ -271,6 +272,7 @@ class TestHttp(unittest.TestCase): empty_sub_label_response = client.post( f"/events/{id}/sub_label", json={"subLabel": ""}, + headers={"remote-role": "admin"}, ) assert empty_sub_label_response.status_code == 200 event = client.get(f"/events/{id}").json() @@ -298,6 +300,7 @@ class TestHttp(unittest.TestCase): client.post( f"/events/{id}/sub_label", json={"subLabel": sub_label}, + headers={"remote-role": "admin"}, ) sub_labels = client.get("/sub_labels").json() assert sub_labels diff --git a/migrations/029_add_user_role.py b/migrations/029_add_user_role.py new file mode 100644 index 000000000..484e0c548 --- /dev/null +++ b/migrations/029_add_user_role.py @@ -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"') diff --git a/web/src/App.tsx b/web/src/App.tsx index ef0a9497e..a0062549f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,6 +10,8 @@ import { Suspense, lazy } from "react"; import { Redirect } from "./components/navigation/Redirect"; import { cn } from "./lib/utils"; import { isPWA } from "./utils/isPWA"; +import ProtectedRoute from "@/components/auth/ProtectedRoute"; +import { AuthProvider } from "@/context/auth-context"; const Live = lazy(() => import("@/pages/Live")); const Events = lazy(() => import("@/pages/Events")); @@ -21,45 +23,58 @@ const Settings = lazy(() => import("@/pages/Settings")); const UIPlayground = lazy(() => import("@/pages/UIPlayground")); const FaceLibrary = lazy(() => import("@/pages/FaceLibrary")); const Logs = lazy(() => import("@/pages/Logs")); +const AccessDenied = lazy(() => import("@/pages/AccessDenied")); function App() { return ( - - -
- {isDesktop && } - {isDesktop && } - {isMobile && } -
- - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + + +
+ {isDesktop && } + {isDesktop && } + {isMobile && } +
+ + + + } + > + } /> + } /> + } /> + } /> + } /> + + } + > + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + +
-
- - + + + ); } diff --git a/web/src/components/auth/AuthForm.tsx b/web/src/components/auth/AuthForm.tsx index 99ce37283..617ce1693 100644 --- a/web/src/components/auth/AuthForm.tsx +++ b/web/src/components/auth/AuthForm.tsx @@ -20,24 +20,23 @@ import { import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; +import { AuthContext } from "@/context/auth-context"; interface UserAuthFormProps extends React.HTMLAttributes {} export function UserAuthForm({ className, ...props }: UserAuthFormProps) { const [isLoading, setIsLoading] = React.useState(false); + const { login } = React.useContext(AuthContext); const formSchema = z.object({ - user: z.string(), - password: z.string(), + user: z.string().min(1, "Username is required"), + password: z.string().min(1, "Password is required"), }); const form = useForm>({ resolver: zodResolver(formSchema), mode: "onChange", - defaultValues: { - user: "", - password: "", - }, + defaultValues: { user: "", password: "" }, }); const onSubmit = async (values: z.infer) => { @@ -50,11 +49,14 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) { password: values.password, }, { - headers: { - "X-CSRF-TOKEN": 1, - }, + headers: { "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; } catch (error) { if (axios.isAxiosError(error)) { diff --git a/web/src/components/auth/ProtectedRoute.tsx b/web/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 000000000..c35fdaebc --- /dev/null +++ b/web/src/components/auth/ProtectedRoute.tsx @@ -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 ( + + ); + } + + // Unauthenticated mode + if (!auth.isAuthenticated) { + return ; + } + + // Authenticated mode (8971): require login + if (!auth.user) { + return ; + } + + // If role is null (shouldn’t happen if isAuthenticated, but type safety), fallback + // though isAuthenticated should catch this + if (auth.user.role === null) { + return ; + } + + if (!requiredRoles.includes(auth.user.role)) { + return ; + } + + return ; +} diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 8aec2a117..cf6881056 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -281,10 +281,13 @@ function NewGroupDialog({ .catch((error) => { setOpen(false); setEditState("none"); - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); @@ -725,10 +728,13 @@ export function CameraGroupEdit({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/components/filter/SearchActionGroup.tsx b/web/src/components/filter/SearchActionGroup.tsx index aac03ad1c..32751a56f 100644 --- a/web/src/components/filter/SearchActionGroup.tsx +++ b/web/src/components/filter/SearchActionGroup.tsx @@ -44,8 +44,12 @@ export default function SearchActionGroup({ pullLatestData(); } }) - .catch(() => { - toast.error("Failed to delete tracked objects.", { + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to delete tracked objects.: ${errorMessage}`, { position: "top-center", }); }); diff --git a/web/src/components/menu/AccountSettings.tsx b/web/src/components/menu/AccountSettings.tsx index 0bc968061..7e948308f 100644 --- a/web/src/components/menu/AccountSettings.tsx +++ b/web/src/components/menu/AccountSettings.tsx @@ -18,22 +18,52 @@ import { } from "../ui/dropdown-menu"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { DialogClose } from "../ui/dialog"; -import { LuLogOut } from "react-icons/lu"; +import { LuLogOut, LuSquarePen } from "react-icons/lu"; import useSWR from "swr"; +import { useState } from "react"; +import axios from "axios"; +import { toast } from "sonner"; +import SetPasswordDialog from "../overlay/SetPasswordDialog"; type AccountSettingsProps = { className?: string; }; + export default function AccountSettings({ className }: AccountSettingsProps) { const { data: profile } = useSWR("profile"); const { data: config } = useSWR("config"); const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`; + const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); + const Container = isDesktop ? DropdownMenu : Drawer; const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger; const Content = isDesktop ? DropdownMenuContent : DrawerContent; 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 ( @@ -65,9 +95,22 @@ export default function AccountSettings({ className }: AccountSettingsProps) { >
- Current User: {profile?.username || "anonymous"} + Current User: {profile?.username || "anonymous"}{" "} + {profile?.role && `(${profile.role})`} + {profile?.username && profile.username !== "anonymous" && ( + setPasswordDialogOpen(true)} + > + + Set Password + + )}
+ setPasswordDialogOpen(false)} + username={profile?.username} + />
); } diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx index c6f920461..b07ace2a3 100644 --- a/web/src/components/menu/GeneralSettings.tsx +++ b/web/src/components/menu/GeneralSettings.tsx @@ -24,7 +24,6 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "../ui/dropdown-menu"; - import { Link } from "react-router-dom"; import { CgDarkMode } from "react-icons/cg"; import { @@ -33,10 +32,8 @@ import { useTheme, } from "@/context/theme-provider"; import { IoColorPalette } from "react-icons/io5"; - import { useState } from "react"; import { useRestart } from "@/api/ws"; - import { Tooltip, TooltipContent, @@ -55,21 +52,27 @@ import { TooltipPortal } from "@radix-ui/react-tooltip"; import { cn } from "@/lib/utils"; import useSWR from "swr"; import RestartDialog from "../overlay/dialog/RestartDialog"; +import { useIsAdmin } from "@/hooks/use-is-admin"; +import SetPasswordDialog from "../overlay/SetPasswordDialog"; +import { toast } from "sonner"; +import axios from "axios"; type GeneralSettingsProps = { className?: string; }; + export default function GeneralSettings({ className }: GeneralSettingsProps) { const { data: profile } = useSWR("profile"); const { data: config } = useSWR("config"); const logoutUrl = config?.proxy?.logout_url || "/api/logout"; - // settings - const { theme, colorScheme, setTheme, setColorScheme } = useTheme(); const [restartDialogOpen, setRestartDialogOpen] = useState(false); + const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); const { send: sendRestart } = useRestart(); + const isAdmin = useIsAdmin(); + const Container = isDesktop ? DropdownMenu : Drawer; const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger; const Content = isDesktop ? DropdownMenuContent : DrawerContent; @@ -79,6 +82,29 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { const SubItemContent = isDesktop ? DropdownMenuSubContent : DialogContent; 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 ( <> @@ -121,13 +147,28 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { >
{isMobile && ( - <> +
- Current User: {profile?.username || "anonymous"} + Current User: {profile?.username || "anonymous"}{" "} + {profile?.role && `(${profile.role})`} + {profile?.username && profile.username !== "anonymous" && ( + setPasswordDialogOpen(true)} + > + + Set Password + + )} Logout +
+ )} + {isAdmin && ( + <> + System + + + + + + System metrics + + + + + + System logs + + + )} - System - - - - - - System metrics - - - - - - System logs - - - - + Configuration @@ -191,143 +238,143 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { Settings - - - - Configuration editor - - - - Appearance - - - - - - Dark Mode - - - - + {isAdmin && ( + <> + setTheme("light")} + aria-label="Configuration editor" > - {theme === "light" ? ( - <> - - Light - - ) : ( - Light - )} + + Configuration editor - setTheme("dark")} - > - {theme === "dark" ? ( - <> - - Dark - - ) : ( - Dark - )} - - setTheme("system")} - > - {theme === "system" ? ( - <> - - System - - ) : ( - System - )} - - - - - - - - Theme - - - - - {colorSchemes.map((scheme) => ( - setColorScheme(scheme)} - > - {scheme === colorScheme ? ( - <> - - {friendlyColorSchemeName(scheme)} - - ) : ( - - {friendlyColorSchemeName(scheme)} - - )} - - ))} - - - + + + )} + + Appearance + + + + + + Dark Mode + + + + + setTheme("light")} + > + {theme === "light" ? ( + <> + + Light + + ) : ( + Light + )} + + setTheme("dark")} + > + {theme === "dark" ? ( + <> + + Dark + + ) : ( + Dark + )} + + setTheme("system")} + > + {theme === "system" ? ( + <> + + System + + ) : ( + System + )} + + + + + + + + Theme + + + + + {colorSchemes.map((scheme) => ( + setColorScheme(scheme)} + > + {scheme === colorScheme ? ( + <> + + {friendlyColorSchemeName(scheme)} + + ) : ( + + {friendlyColorSchemeName(scheme)} + + )} + + ))} + + + Help @@ -357,17 +404,25 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { GitHub - - setRestartDialogOpen(true)} - > - - Restart Frigate - + {isAdmin && ( + <> + + setRestartDialogOpen(true)} + > + + Restart Frigate + + + )}
@@ -376,6 +431,12 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { onClose={() => setRestartDialogOpen(false)} onRestart={() => sendRestart("restart")} /> + setPasswordDialogOpen(false)} + username={profile?.username} + /> ); } diff --git a/web/src/components/menu/SearchResultActions.tsx b/web/src/components/menu/SearchResultActions.tsx index 8db67e43e..4d1fd4966 100644 --- a/web/src/components/menu/SearchResultActions.tsx +++ b/web/src/components/menu/SearchResultActions.tsx @@ -74,8 +74,12 @@ export default function SearchResultActions({ refreshResults(); } }) - .catch(() => { - toast.error("Failed to delete tracked object.", { + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to delete tracked object: ${errorMessage}`, { position: "top-center", }); }); diff --git a/web/src/components/overlay/CreateUserDialog.tsx b/web/src/components/overlay/CreateUserDialog.tsx index 7d44159dd..89403c37f 100644 --- a/web/src/components/overlay/CreateUserDialog.tsx +++ b/web/src/components/overlay/CreateUserDialog.tsx @@ -2,6 +2,7 @@ import { Button } from "../ui/button"; import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -12,20 +13,31 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; import ActivityIndicator from "../indicators/activity-indicator"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Dialog, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { Shield, User } from "lucide-react"; +import { LuCheck, LuX } from "react-icons/lu"; type CreateUserOverlayProps = { show: boolean; - onCreate: (user: string, password: string) => void; + onCreate: (user: string, password: string, role: "admin" | "viewer") => void; onCancel: () => void; }; + export default function CreateUserDialog({ show, onCreate, @@ -33,15 +45,22 @@ export default function CreateUserDialog({ }: CreateUserOverlayProps) { const [isLoading, setIsLoading] = useState(false); - const formSchema = z.object({ - user: z - .string() - .min(1) - .regex(/^[A-Za-z0-9._]+$/, { - message: "Username may only include letters, numbers, . or _", - }), - password: z.string(), - }); + const formSchema = z + .object({ + user: z + .string() + .min(1, "Username is required") + .regex(/^[A-Za-z0-9._]+$/, { + message: "Username may only include letters, numbers, . or _", + }), + 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>({ resolver: zodResolver(formSchema), @@ -49,32 +68,93 @@ export default function CreateUserDialog({ defaultValues: { user: "", password: "", + confirmPassword: "", + role: "viewer", }, }); const onSubmit = async (values: z.infer) => { setIsLoading(true); - await onCreate(values.user, values.password); + await onCreate(values.user, values.password, values.role); form.reset(); 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 ( - + - Create User + Create New User + + Add a new user account and specify an role for access to areas of + the Frigate UI. + +
- + ( - User + + Username + + + + Only letters, numbers, periods and underscores allowed. + + + + )} + /> + + ( + + + Password + + + @@ -82,30 +162,121 @@ export default function CreateUserDialog({ )} /> + ( - Password + + Confirm Password + + {showMatchIndicator && ( +
+ {passwordsMatch ? ( + <> + + + Passwords match + + + ) : ( + <> + + + Passwords don't match + + + )} +
+ )} +
)} /> - - + + ( + + Role + + + Admins have full access to all features in the Frigate UI. + Viewers are limited to viewing cameras, review items, and + historical footage in the UI. + + + + )} + /> + + +
+
+ + +
+
diff --git a/web/src/components/overlay/DeleteUserDialog.tsx b/web/src/components/overlay/DeleteUserDialog.tsx index 8638b9145..e8dfb79c1 100644 --- a/web/src/components/overlay/DeleteUserDialog.tsx +++ b/web/src/components/overlay/DeleteUserDialog.tsx @@ -6,34 +6,61 @@ import { DialogHeader, DialogTitle, } from "../ui/dialog"; +import { DialogDescription } from "@radix-ui/react-dialog"; -type SetPasswordProps = { +type DeleteUserDialogProps = { show: boolean; + username?: string; onDelete: () => void; onCancel: () => void; }; export default function DeleteUserDialog({ show, + username, onDelete, onCancel, -}: SetPasswordProps) { +}: DeleteUserDialogProps) { return ( - - - Delete User + + +
+ Delete User + + This action cannot be undone. This will permanently delete the + user account and remove all associated data. + +
-
Are you sure?
- - + +
+

+ Are you sure you want to delete{" "} + {username}? +

+
+ + +
+
+ + +
+
diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index 00966e06a..4f49abaf0 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -99,16 +99,13 @@ export default function ExportDialog({ } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error( - `Failed to start export: ${error.response.data.message}`, - { position: "top-center" }, - ); - } else { - toast.error(`Failed to start export: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to start export: ${errorMessage}`, { + position: "top-center", + }); }); }, [camera, name, range, setRange, setName, setMode]); diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx index 0a316acc7..81b1eefe9 100644 --- a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx +++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx @@ -106,16 +106,13 @@ export default function MobileReviewSettingsDrawer({ } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error( - `Failed to start export: ${error.response.data.message}`, - { position: "top-center" }, - ); - } else { - toast.error(`Failed to start export: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to start export: ${errorMessage}`, { + position: "top-center", + }); }); }, [camera, name, range, setRange, setName, setMode]); diff --git a/web/src/components/overlay/RoleChangeDialog.tsx b/web/src/components/overlay/RoleChangeDialog.tsx new file mode 100644 index 000000000..577c748ff --- /dev/null +++ b/web/src/components/overlay/RoleChangeDialog.tsx @@ -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 ( + + + + + Change User Role + + + Update permissions for{" "} + {username} + + + +
+
+

Select the appropriate role for this user:

+
    +
  • + • Admin: Full access to all + features. +
  • +
  • + • Viewer: Limited to Live + dashboards, Review, Explore, and Exports only. +
  • +
+
+ + +
+ + +
+
+ + +
+
+
+
+
+ ); +} diff --git a/web/src/components/overlay/SetPasswordDialog.tsx b/web/src/components/overlay/SetPasswordDialog.tsx index 2f6cc4eaf..108b568d7 100644 --- a/web/src/components/overlay/SetPasswordDialog.tsx +++ b/web/src/components/overlay/SetPasswordDialog.tsx @@ -1,50 +1,202 @@ +"use client"; + import { Button } from "../ui/button"; import { Input } from "../ui/input"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Dialog, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../ui/dialog"; +import { Label } from "../ui/label"; +import { LuCheck, LuX } from "react-icons/lu"; type SetPasswordProps = { show: boolean; onSave: (password: string) => void; onCancel: () => void; + username?: string; }; + export default function SetPasswordDialog({ show, onSave, onCancel, + username, }: SetPasswordProps) { - const [password, setPassword] = useState(); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordStrength, setPasswordStrength] = useState(0); + const [error, setError] = useState(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 ( - e.preventDefault()}> - - Set Password + + + + {username ? `Update Password for ${username}` : "Set Password"} + + + Create a strong password to secure this account. + - setPassword(event.target.value)} - /> - - + +
+
+ + { + setPassword(event.target.value); + setError(null); + }} + placeholder="Enter new password" + autoFocus + /> + + {/* Password strength indicator */} + {password && ( +
+
+
+
+

+ Password strength:{" "} + {getStrengthLabel()} +

+
+ )} +
+ +
+ + { + setConfirmPassword(event.target.value); + setError(null); + }} + placeholder="Confirm new password" + /> + + {/* Password match indicator */} + {password && confirmPassword && ( +
+ {password === confirmPassword ? ( + <> + + Passwords match + + ) : ( + <> + + Passwords don't match + + )} +
+ )} +
+ + {error && ( +
+ {error} +
+ )} +
+ + +
+
+ + +
+
diff --git a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx index 79d078c1f..df529c0dc 100644 --- a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx +++ b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx @@ -87,10 +87,13 @@ export function AnnotationSettingsPane({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 9d3610e49..c94c2cd2d 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -394,8 +394,12 @@ function ObjectDetailsTab({ }, ); }) - .catch(() => { - toast.error("Failed to update the description", { + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to update the description: ${errorMessage}`, { position: "top-center", }); setDesc(search.data.description); @@ -422,11 +426,13 @@ function ObjectDetailsTab({ } }) .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; toast.error( - `Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${error.response.data.message}`, - { - position: "top-center", - }, + `Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${errorMessage}`, + { position: "top-center" }, ); }); }, @@ -492,8 +498,12 @@ function ObjectDetailsTab({ setIsSubLabelDialogOpen(false); } }) - .catch(() => { - toast.error("Failed to update sub label.", { + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to update sub label: ${errorMessage}`, { position: "top-center", }); }); diff --git a/web/src/components/settings/MotionMaskEditPane.tsx b/web/src/components/settings/MotionMaskEditPane.tsx index 3b73c6a23..5c83f7720 100644 --- a/web/src/components/settings/MotionMaskEditPane.tsx +++ b/web/src/components/settings/MotionMaskEditPane.tsx @@ -176,10 +176,13 @@ export default function MotionMaskEditPane({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/components/settings/ObjectMaskEditPane.tsx b/web/src/components/settings/ObjectMaskEditPane.tsx index 2c63d2e63..32e878c41 100644 --- a/web/src/components/settings/ObjectMaskEditPane.tsx +++ b/web/src/components/settings/ObjectMaskEditPane.tsx @@ -208,10 +208,13 @@ export default function ObjectMaskEditPane({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/components/settings/PolygonItem.tsx b/web/src/components/settings/PolygonItem.tsx index 707df7a8f..db3f173a3 100644 --- a/web/src/components/settings/PolygonItem.tsx +++ b/web/src/components/settings/PolygonItem.tsx @@ -186,10 +186,13 @@ export default function PolygonItem({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index c6c5ee474..7adb3e194 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -414,10 +414,13 @@ export default function ZoneEditPane({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/context/auth-context.tsx b/web/src/context/auth-context.tsx new file mode 100644 index 000000000..a047d6fa3 --- /dev/null +++ b/web/src/context/auth-context.tsx @@ -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({ + auth: { user: null, isLoading: true, isAuthenticated: false }, + login: () => {}, + logout: () => {}, +}); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [auth, setAuth] = useState({ + 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 ( + + {children} + + ); +} diff --git a/web/src/context/providers.tsx b/web/src/context/providers.tsx index 61b4a6426..b0a5f55c9 100644 --- a/web/src/context/providers.tsx +++ b/web/src/context/providers.tsx @@ -6,6 +6,7 @@ import { IconContext } from "react-icons"; import { TooltipProvider } from "@/components/ui/tooltip"; import { StatusBarMessagesProvider } from "@/context/statusbar-provider"; import { StreamingSettingsProvider } from "./streaming-settings-provider"; +import { AuthProvider } from "./auth-context"; type TProvidersProps = { children: ReactNode; @@ -14,19 +15,21 @@ type TProvidersProps = { function providers({ children }: TProvidersProps) { return ( - - - - - - - {children} - - - - - - + + + + + + + + {children} + + + + + + + ); } diff --git a/web/src/hooks/use-is-admin.ts b/web/src/hooks/use-is-admin.ts new file mode 100644 index 000000000..222a43fce --- /dev/null +++ b/web/src/hooks/use-is-admin.ts @@ -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; +} diff --git a/web/src/pages/AccessDenied.tsx b/web/src/pages/AccessDenied.tsx new file mode 100644 index 000000000..53d83282b --- /dev/null +++ b/web/src/pages/AccessDenied.tsx @@ -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 ( +
+ + + Access Denied + +

+ You don't have permission to view this page. +

+
+ ); +} diff --git a/web/src/pages/ConfigEditor.tsx b/web/src/pages/ConfigEditor.tsx index bcb0c4c65..a8ca0eda3 100644 --- a/web/src/pages/ConfigEditor.tsx +++ b/web/src/pages/ConfigEditor.tsx @@ -59,11 +59,12 @@ function ConfigEditor() { .catch((error) => { toast.error("Error saving config", { position: "top-center" }); - if (error.response) { - setError(error.response.data.message); - } else { - setError(error.message); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + + setError(errorMessage); }); }, [editorRef], diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx index 529bb2e26..93cfa6b11 100644 --- a/web/src/pages/Exports.tsx +++ b/web/src/pages/Exports.tsx @@ -93,16 +93,13 @@ function Exports() { } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error( - `Failed to rename export: ${error.response.data.message}`, - { position: "top-center" }, - ); - } else { - toast.error(`Failed to rename export: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to rename export: ${errorMessage}`, { + position: "top-center", + }); }); }, [mutate], diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index 8daf7e325..b9d3ee71a 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -99,16 +99,13 @@ export default function FaceLibrary() { } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error( - `Failed to upload image: ${error.response.data.message}`, - { position: "top-center" }, - ); - } else { - toast.error(`Failed to upload image: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to upload image: ${errorMessage}`, { + position: "top-center", + }); }); }, [pageToggle, refreshFaces], @@ -132,16 +129,13 @@ export default function FaceLibrary() { } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error( - `Failed to set face name: ${error.response.data.message}`, - { position: "top-center" }, - ); - } else { - toast.error(`Failed to set face name: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to set face name: ${errorMessage}`, { + position: "top-center", + }); }); }, [refreshFaces], @@ -308,15 +302,13 @@ function FaceAttempt({ } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error(`Failed to train: ${error.response.data.message}`, { - position: "top-center", - }); - } else { - toast.error(`Failed to train: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to train: ${errorMessage}`, { + position: "top-center", + }); }); }, [image, onRefresh], @@ -334,18 +326,13 @@ function FaceAttempt({ } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error( - `Failed to update score: ${error.response.data.message}`, - { - position: "top-center", - }, - ); - } else { - toast.error(`Failed to update score: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to update face score: ${errorMessage}`, { + position: "top-center", + }); }); }, [image, onRefresh]); @@ -361,15 +348,13 @@ function FaceAttempt({ } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error(`Failed to delete: ${error.response.data.message}`, { - position: "top-center", - }); - } else { - toast.error(`Failed to delete: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to delete: ${errorMessage}`, { + position: "top-center", + }); }); }, [image, onRefresh]); @@ -478,15 +463,13 @@ function FaceImage({ name, image, onRefresh }: FaceImageProps) { } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error(`Failed to delete: ${error.response.data.message}`, { - position: "top-center", - }); - } else { - toast.error(`Failed to delete: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to delete: ${errorMessage}`, { + position: "top-center", + }); }); }, [name, image, onRefresh]); diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 33f854ba3..30be2afc2 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -40,6 +40,7 @@ import UiSettingsView from "@/views/settings/UiSettingsView"; import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useSearchParams } from "react-router-dom"; import { useInitialCameraState } from "@/api/ws"; +import { useIsAdmin } from "@/hooks/use-is-admin"; const allSettingsViews = [ "UI settings", @@ -62,6 +63,15 @@ export default function Settings() { const [searchParams] = useSearchParams(); + // auth and roles + + const isAdmin = useIsAdmin(); + + const allowedViewsForViewer: SettingsType[] = ["UI settings", "debug"]; + const visibleSettingsViews = !isAdmin + ? allowedViewsForViewer + : allSettingsViews; + // TODO: confirm leave page const [unsavedChanges, setUnsavedChanges] = useState(false); const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); @@ -149,7 +159,12 @@ export default function Settings() { useSearchEffect("page", (page: string) => { 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 return !searchParams.has("object_mask"); @@ -180,11 +195,16 @@ export default function Settings() { value={pageToggle} onValueChange={(value: SettingsType) => { 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) => ( { - if (error.response?.data?.message) { - toast.error( - `Failed to start export: ${error.response.data.message}`, - { position: "top-center" }, - ); - } else { - toast.error(`Failed to start export: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to start export: ${errorMessage}`, { + position: "top-center", + }); }); }, [reviewItems], diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index cacdc7c1d..15dea59d6 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -116,6 +116,7 @@ import { Switch } from "@/components/ui/switch"; import axios from "axios"; import { toast } from "sonner"; import { Toaster } from "@/components/ui/sonner"; +import { useIsAdmin } from "@/hooks/use-is-admin"; type LiveCameraViewProps = { config?: FrigateConfig; @@ -982,6 +983,10 @@ function FrigateCameraFeatures({ const { payload: autotrackingState, send: sendAutotracking } = useAutotrackingState(camera.name); + // roles + + const isAdmin = useIsAdmin(); + // manual event const recordingEventIdRef = useRef(null); @@ -1080,65 +1085,71 @@ function FrigateCameraFeatures({ if (isDesktop || isTablet) { return ( <> - sendEnabled(enabledState == "ON" ? "OFF" : "ON")} - disabled={false} - /> - sendDetect(detectState == "ON" ? "OFF" : "ON")} - disabled={!cameraEnabled} - /> - sendRecord(recordState == "ON" ? "OFF" : "ON")} - disabled={!cameraEnabled} - /> - sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")} - disabled={!cameraEnabled} - /> - {audioDetectEnabled && ( - sendAudio(audioState == "ON" ? "OFF" : "ON")} - disabled={!cameraEnabled} - /> - )} - {autotrackingEnabled && ( - - sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") - } - disabled={!cameraEnabled} - /> + {isAdmin && ( + <> + sendEnabled(enabledState == "ON" ? "OFF" : "ON")} + disabled={false} + /> + sendDetect(detectState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} + /> + sendRecord(recordState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} + /> + sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} + /> + {audioDetectEnabled && ( + sendAudio(audioState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} + /> + )} + {autotrackingEnabled && ( + + sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") + } + disabled={!cameraEnabled} + /> + )} + )}
- - sendEnabled(enabledState == "ON" ? "OFF" : "ON") - } - /> - - sendDetect(detectState == "ON" ? "OFF" : "ON") - } - /> - {recordingEnabled && ( - - sendRecord(recordState == "ON" ? "OFF" : "ON") - } - /> - )} - - sendSnapshot(snapshotState == "ON" ? "OFF" : "ON") - } - /> - {audioDetectEnabled && ( - - sendAudio(audioState == "ON" ? "OFF" : "ON") - } - /> - )} - {autotrackingEnabled && ( - - sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") - } - /> + {isAdmin && ( + <> + + sendEnabled(enabledState == "ON" ? "OFF" : "ON") + } + /> + + sendDetect(detectState == "ON" ? "OFF" : "ON") + } + /> + {recordingEnabled && ( + + sendRecord(recordState == "ON" ? "OFF" : "ON") + } + /> + )} + + sendSnapshot(snapshotState == "ON" ? "OFF" : "ON") + } + /> + {audioDetectEnabled && ( + + sendAudio(audioState == "ON" ? "OFF" : "ON") + } + /> + )} + {autotrackingEnabled && ( + + sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") + } + /> + )} + )}
+
{!isRestreamed && (
diff --git a/web/src/views/settings/AuthenticationView.tsx b/web/src/views/settings/AuthenticationView.tsx index 1c6df5c52..118d102d4 100644 --- a/web/src/views/settings/AuthenticationView.tsx +++ b/web/src/views/settings/AuthenticationView.tsx @@ -11,10 +11,25 @@ import axios from "axios"; import CreateUserDialog from "@/components/overlay/CreateUserDialog"; import { toast } from "sonner"; import DeleteUserDialog from "@/components/overlay/DeleteUserDialog"; -import { Card } from "@/components/ui/card"; import { HiTrash } from "react-icons/hi"; import { FaUserEdit } from "react-icons/fa"; -import { LuPlus } from "react-icons/lu"; +import { LuPlus, LuShield, LuUserCog } from "react-icons/lu"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import RoleChangeDialog from "@/components/overlay/RoleChangeDialog"; export default function AuthenticationView() { const { data: config } = useSWR("config"); @@ -23,8 +38,12 @@ export default function AuthenticationView() { const [showSetPassword, setShowSetPassword] = useState(false); const [showCreate, setShowCreate] = useState(false); const [showDelete, setShowDelete] = useState(false); + const [showRoleChange, setShowRoleChange] = useState(false); const [selectedUser, setSelectedUser] = useState(); + const [selectedUserRole, setSelectedUserRole] = useState< + "admin" | "viewer" + >(); useEffect(() => { document.title = "Authentication Settings - Frigate"; @@ -32,142 +51,303 @@ export default function AuthenticationView() { const onSavePassword = useCallback((user: string, password: string) => { axios - .put(`users/${user}/password`, { - password: password, - }) + .put(`users/${user}/password`, { password }) .then((response) => { - if (response.status == 200) { + if (response.status === 200) { setShowSetPassword(false); + toast.success("Password updated successfully", { + position: "top-center", + }); } }) - .catch((_error) => { - toast.error("Error setting password", { + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save password: ${errorMessage}`, { position: "top-center", }); }); }, []); - const onCreate = async (user: string, password: string) => { - try { - await axios.post("users", { - username: user, - password: password, + const onCreate = ( + user: string, + password: string, + role: "admin" | "viewer", + ) => { + axios + .post("users", { username: user, password, role }) + .then((response) => { + if (response.status === 200 || response.status === 201) { + setShowCreate(false); + mutateUsers((users) => { + users?.push({ username: user, role: role }); + return users; + }, false); + toast.success(`User ${user} created successfully`, { + position: "top-center", + }); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to create user: ${errorMessage}`, { + position: "top-center", + }); }); - setShowCreate(false); - mutateUsers((users) => { - users?.push({ username: user }); - return users; - }, false); - } catch (error) { - toast.error("Error creating user. Check server logs.", { - position: "top-center", - }); - } }; - const onDelete = async (user: string) => { - try { - await axios.delete(`users/${user}`); - setShowDelete(false); - mutateUsers((users) => { - return users?.filter((u) => { - return u.username !== user; + const onDelete = (user: string) => { + axios + .delete(`users/${user}`) + .then((response) => { + if (response.status === 200) { + setShowDelete(false); + mutateUsers( + (users) => users?.filter((u) => u.username !== user), + false, + ); + toast.success(`User ${user} deleted successfully`, { + position: "top-center", + }); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to delete user: ${errorMessage}`, { + position: "top-center", + }); + }); + }; + + const onChangeRole = (user: string, newRole: "admin" | "viewer") => { + if (user === "admin") return; // Prevent role change for 'admin' + + axios + .put(`users/${user}/role`, { role: newRole }) + .then((response) => { + if (response.status === 200) { + setShowRoleChange(false); + mutateUsers( + (users) => + users?.map((u) => + u.username === user ? { ...u, role: newRole } : u, + ), + false, + ); + toast.success(`Role updated for ${user}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to update role: ${errorMessage}`, { + position: "top-center", }); - }, false); - } catch (error) { - toast.error("Error deleting user. Check server logs.", { - position: "top-center", }); - } }; if (!config || !users) { - return ; + return ( +
+ +
+ ); } return (
-
- - Users - +
+
+ + User Management + +

+ Manage this Frigate instance's user accounts. +

+
-
- {users.map((u) => ( - -
-
- {u.username} -
-
- - -
-
-
- ))} +
+
+
+ + + + Username + Role + Actions + + + + {users.length === 0 ? ( + + + No users found. + + + ) : ( + users.map((user) => ( + + +
+ {user.username === "admin" ? ( + + ) : ( + + )} + {user.username} +
+
+ + + {user.role || "viewer"} + + + + +
+ {user.username !== "admin" && ( + + + + + +

Change user role

+
+
+ )} + + + + + + +

Update password

+
+
+ + {user.username !== "admin" && ( + + + + + +

Delete user

+
+
+ )} +
+
+
+
+ )) + )} +
+
+
+
+ { - setShowSetPassword(false); - }} - onSave={(password) => { - onSavePassword(selectedUser!, password); - }} + onCancel={() => setShowSetPassword(false)} + onSave={(password) => onSavePassword(selectedUser!, password)} /> { - setShowDelete(false); - }} - onDelete={() => { - onDelete(selectedUser!); - }} + username={selectedUser ?? "this user"} + onCancel={() => setShowDelete(false)} + onDelete={() => onDelete(selectedUser!)} /> { - setShowCreate(false); - }} + onCancel={() => setShowCreate(false)} /> + {selectedUser && selectedUserRole && ( + onChangeRole(selectedUser, role)} + onCancel={() => setShowRoleChange(false)} + /> + )}
); } diff --git a/web/src/views/settings/CameraSettingsView.tsx b/web/src/views/settings/CameraSettingsView.tsx index e2c1ca563..f83bdde50 100644 --- a/web/src/views/settings/CameraSettingsView.tsx +++ b/web/src/views/settings/CameraSettingsView.tsx @@ -171,10 +171,13 @@ export default function CameraSettingsView({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx index d30de487e..a7dd1c9d4 100644 --- a/web/src/views/settings/NotificationsSettingsView.tsx +++ b/web/src/views/settings/NotificationsSettingsView.tsx @@ -267,10 +267,13 @@ export default function NotificationView({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/views/settings/SearchSettingsView.tsx b/web/src/views/settings/SearchSettingsView.tsx index 027f55070..b3f35bde7 100644 --- a/web/src/views/settings/SearchSettingsView.tsx +++ b/web/src/views/settings/SearchSettingsView.tsx @@ -103,10 +103,13 @@ export default function SearchSettingsView({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/views/settings/UiSettingsView.tsx b/web/src/views/settings/UiSettingsView.tsx index e3b5c8c7a..03375670f 100644 --- a/web/src/views/settings/UiSettingsView.tsx +++ b/web/src/views/settings/UiSettingsView.tsx @@ -38,10 +38,13 @@ export default function UiSettingsView() { }); }) .catch((error) => { - toast.error( - `Failed to clear stored layout: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to clear stored layout: ${errorMessage}`, { + position: "top-center", + }); }); }); }, [config]); @@ -58,10 +61,13 @@ export default function UiSettingsView() { }); }) .catch((error) => { - toast.error( - `Failed to clear camera groups streaming settings: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to clear streaming settings: ${errorMessage}`, { + position: "top-center", + }); }); }, [config]);