Enhance user roles to limit camera access (#20024)

* update config for roles and add validator

* ensure admin and viewer are never overridden

* add class method to user to retrieve all allowed cameras

* enforce config roles in auth api endpoints

* add camera access api dependency functions

* protect review endpoints

* protect preview endpoints

* rename param name for better fastapi injection matching

* remove unneeded

* protect export endpoints

* protect event endpoints

* protect media endpoints

* update auth hook for allowed cameras

* update default app view

* ensure anonymous user always returns all cameras

* limit cameras in explore

* cameras is already a list

* limit cameras in review/history

* limit cameras in live view

* limit cameras in camera groups

* only show face library and classification in sidebar for admin

* remove check in delete reviews

since admin role is required, no need to check camera access. fixes failing test

* pass request with camera access for tests

* more async

* camera access tests

* fix proxy auth tests

* allowed cameras for review tests

* combine event tests and refactor for camera access

* fix post validation for roles

* don't limit roles in create user dialog

* fix triggers endpoints

no need to run require camera access dep since the required role is admin

* fix type

* create and edit role dialogs

* delete role dialog

* fix role change dialog

* update settings view for roles

* i18n changes

* minor spacing tweaks

* docs

* use badges and camera name label component

* clarify docs

* display all cameras badge for admin and viewer

* i18n fix

* use validator to prevent reserved and empty roles from being assigned

* split users and roles into separate tabs in settings

* tweak docs

* clarify docs

* change icon

* don't memoize roles

always recalculate on component render
This commit is contained in:
Josh Hawkins 2025-09-12 06:19:29 -05:00 committed by GitHub
parent ba650af6f2
commit ed1e3a7c9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 2286 additions and 739 deletions

View File

@ -124,7 +124,7 @@ proxy:
role: x-forwarded-groups
```
Frigate supports both `admin` and `viewer` roles (see below). When using port `8971`, Frigate validates these headers and subsequent requests use the headers `remote-user` and `remote-role` for authorization.
Frigate supports `admin`, `viewer`, and custom roles (see below). When using port `8971`, Frigate validates these headers and subsequent requests use the headers `remote-user` and `remote-role` for authorization.
A default role can be provided. Any value in the mapped `role` header will override the default.
@ -136,7 +136,7 @@ proxy:
## Role mapping
In some environments, upstream identity providers (OIDC, SAML, LDAP, etc.) do not pass a Frigate-compatible role directly, but instead pass one or more group claims. To handle this, Frigate supports a `role_map` that translates upstream group names into Frigates internal roles (`admin` or `viewer`).
In some environments, upstream identity providers (OIDC, SAML, LDAP, etc.) do not pass a Frigate-compatible role directly, but instead pass one or more group claims. To handle this, Frigate supports a `role_map` that translates upstream group names into Frigates internal roles (`admin`, `viewer`, or custom).
```yaml
proxy:
@ -150,14 +150,17 @@ proxy:
- access-level-security
viewer:
- camera-viewer
operator: # Custom role mapping
- operators
```
In this example:
- If the proxy passes a role header containing `sysadmins` or `access-level-security`, the user is assigned the `admin` role.
- If the proxy passes a role header containing `camera-viewer`, the user is assigned the `viewer` role.
- If the proxy passes a role header containing `operators`, the user is assigned the `operator` custom role.
- If no mapping matches, Frigate falls back to `default_role` if configured.
- If `role_map` is not defined, Frigate assumes the role header directly contains `admin` or `viewer`.
- If `role_map` is not defined, Frigate assumes the role header directly contains `admin`, `viewer`, or a custom role name.
#### Port Considerations
@ -167,6 +170,7 @@ In this example:
- The `remote-role` header determines the users privileges:
- **admin** → Full access (user management, configuration changes).
- **viewer** → Read-only access.
- **Custom roles** → Read-only access limited to the cameras defined in `auth.roles[role]`.
- Ensure your **proxy sends both user and role headers** for proper role enforcement.
**Unauthenticated Port (5000)**
@ -212,6 +216,41 @@ Frigate supports user roles to control access to certain features in the UI and
- **admin**: Full access to all features, including user management and configuration.
- **viewer**: Read-only access to the UI and API, including viewing cameras, review items, and historical footage. Configuration editor and settings in the UI are inaccessible.
- **Custom Roles**: Arbitrary role names (alphanumeric, dots/underscores) with specific camera permissions. These extend the system for granular access (e.g., "operator" for select cameras).
### Custom Roles and Camera Access
The viewer role provides read-only access to all cameras in the UI and API. Custom roles allow admins to limit read-only access to specific cameras. Each role specifies an array of allowed camera names. If a user is assigned a custom role, their account is like the **viewer** role - they can only view Live, Review/History, Explore, and Export for the designated cameras. Backend API endpoints enforce this server-side (e.g., returning 403 for unauthorized cameras), and the frontend UI filters content accordingly (e.g., camera dropdowns show only permitted options).
### Role Configuration Example
```yaml
cameras:
front_door:
# ... camera config
side_yard:
# ... camera config
garage:
# ... camera config
auth:
enabled: true
roles:
operator: # Custom role
- front_door
- garage # Operator can access front and garage
neighbor:
- side_yard
```
If you want to provide access to all cameras to a specific user, just use the **viewer** role.
### Managing User Roles
1. Log in as an **admin** user via port `8971` (preferred), or unauthenticated via port `5000`.
2. Navigate to **Settings**.
3. In the **Users** section, edit a users role by selecting from available roles (admin, viewer, or custom).
4. In the **Roles** section, add/edit/delete custom roles (select cameras via switches). Deleting a role auto-reassigns users to "viewer".
### Role Enforcement

View File

@ -11,7 +11,7 @@ import secrets
import time
from datetime import datetime
from pathlib import Path
from typing import List
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
@ -33,7 +33,6 @@ from frigate.models import User
logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.auth])
VALID_ROLES = ["admin", "viewer"]
class RateLimiter:
@ -204,6 +203,7 @@ async def get_current_user(request: Request):
def require_role(required_roles: List[str]):
async def role_checker(request: Request):
proxy_config: ProxyConfig = request.app.frigate_config.proxy
config_roles = list(request.app.frigate_config.auth.roles.keys())
# Get role from header (could be comma-separated)
role_header = request.headers.get("remote-role")
@ -217,12 +217,12 @@ def require_role(required_roles: List[str]):
if not roles:
raise HTTPException(status_code=403, detail="Role not provided")
# enforce VALID_ROLES
valid_roles = [r for r in roles if r in VALID_ROLES]
# enforce config roles
valid_roles = [r for r in roles if r in config_roles]
if not valid_roles:
raise HTTPException(
status_code=403,
detail=f"No valid roles found in {roles}. Required: {', '.join(required_roles)}",
detail=f"No valid roles found in {roles}. Required: {', '.join(required_roles)}. Available: {', '.join(config_roles)}",
)
if not any(role in required_roles for role in valid_roles):
@ -238,7 +238,9 @@ def require_role(required_roles: List[str]):
return role_checker
def resolve_role(headers: dict, proxy_config: ProxyConfig) -> str:
def resolve_role(
headers: dict, proxy_config: ProxyConfig, config_roles: set[str]
) -> str:
"""
Determine the effective role for a request based on proxy headers and configuration.
@ -247,31 +249,40 @@ def resolve_role(headers: dict, proxy_config: ProxyConfig) -> str:
- If a role_map is configured, treat the header as group claims
(split by proxy_config.separator) and map to roles.
- If no role_map is configured, treat the header as role names directly.
2. If no valid role is found, return proxy_config.default_role.
2. If no valid role is found, return proxy_config.default_role if it's valid in config_roles, else 'viewer'.
Args:
headers (dict): Incoming request headers (case-insensitive).
proxy_config (ProxyConfig): Proxy configuration.
config_roles (set[str]): Set of valid roles from config.
Returns:
str: Resolved role (always one of VALID_ROLES).
str: Resolved role (one of config_roles or validated default).
"""
role = proxy_config.default_role
default_role = proxy_config.default_role
role_header = proxy_config.header_map.role
# Validate default_role against config; fallback to 'viewer' if invalid
validated_default = default_role if default_role in config_roles else "viewer"
if not config_roles:
validated_default = "viewer" # Edge case: no roles defined
if not role_header:
logger.debug(
"No role header configured in proxy_config.header_map. Returning default role '%s'.",
role,
"No role header configured in proxy_config.header_map. Returning validated default role '%s'.",
validated_default,
)
return role
return validated_default
raw_value = headers.get(role_header, "")
logger.debug("Raw role header value from '%s': %r", role_header, raw_value)
if not raw_value:
logger.debug("Role header missing or empty. Returning default role '%s'.", role)
return role
logger.debug(
"Role header missing or empty. Returning validated default role '%s'.",
validated_default,
)
return validated_default
# role_map configured, treat header as group claims
if proxy_config.header_map.role_map:
@ -288,16 +299,18 @@ def resolve_role(headers: dict, proxy_config: ProxyConfig) -> str:
logger.debug("Matched roles from role_map: %s", matched_roles)
if matched_roles:
resolved = next((r for r in VALID_ROLES if r in matched_roles), role)
resolved = next(
(r for r in config_roles if r in matched_roles), validated_default
)
logger.debug("Resolved role (with role_map) to '%s'.", resolved)
return resolved
logger.debug(
"No role_map match for groups '%s'. Using default role '%s'.",
"No role_map match for groups '%s'. Using validated default role '%s'.",
raw_value,
proxy_config.default_role,
validated_default,
)
return role
return validated_default
# no role_map, treat as role names directly
roles_from_header = [
@ -306,14 +319,14 @@ def resolve_role(headers: dict, proxy_config: ProxyConfig) -> str:
logger.debug("Parsed roles directly from header: %s", roles_from_header)
resolved = next(
(r for r in VALID_ROLES if r in roles_from_header),
proxy_config.default_role,
(r for r in config_roles if r in roles_from_header),
validated_default,
)
if resolved == proxy_config.default_role and roles_from_header:
if resolved == validated_default and roles_from_header:
logger.debug(
"Provided proxy role header values '%s' did not contain a valid role. Using default role '%s'.",
"Provided proxy role header values '%s' did not contain a valid role. Using validated default role '%s'.",
raw_value,
proxy_config.default_role,
validated_default,
)
else:
logger.debug("Resolved role (direct header) to '%s'.", resolved)
@ -358,7 +371,8 @@ def auth(request: Request):
)
# parse header and resolve a valid role
role = resolve_role(request.headers, proxy_config)
config_roles_set = set(auth_config.roles.keys())
role = resolve_role(request.headers, proxy_config, config_roles_set)
success_response.headers["remote-role"] = role
return success_response
@ -452,7 +466,13 @@ def profile(request: Request):
username = request.headers.get("remote-user", "anonymous")
role = request.headers.get("remote-role", "viewer")
return JSONResponse(content={"username": username, "role": role})
all_camera_names = set(request.app.frigate_config.cameras.keys())
roles_dict = request.app.frigate_config.auth.roles
allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names)
return JSONResponse(
content={"username": username, "role": role, "allowed_cameras": allowed_cameras}
)
@router.get("/logout")
@ -483,8 +503,12 @@ 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 VALID_ROLES:
role = "viewer" # Enforce valid roles
config_roles_set = set(request.app.frigate_config.auth.roles.keys())
if role not in config_roles_set:
logger.warning(
f"User {db_user.username} has an invalid role {role}, falling back to 'viewer'."
)
role = "viewer"
expiration = int(time.time()) + JWT_SESSION_LENGTH
encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
response = Response("", 200)
@ -509,11 +533,17 @@ def create_user(
body: AppPostUsersBody,
):
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
config_roles = list(request.app.frigate_config.auth.roles.keys())
if not re.match("^[A-Za-z0-9._]+$", body.username):
return JSONResponse(content={"message": "Invalid username"}, status_code=400)
role = body.role if body.role in VALID_ROLES else "viewer"
if body.role not in config_roles:
return JSONResponse(
content={"message": f"Role must be one of: {', '.join(config_roles)}"},
status_code=400,
)
role = body.role or "viewer"
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
User.insert(
{
@ -584,10 +614,52 @@ async def update_role(
return JSONResponse(
content={"message": "Cannot modify admin user's role"}, status_code=403
)
if body.role not in VALID_ROLES:
config_roles = list(request.app.frigate_config.auth.roles.keys())
if body.role not in config_roles:
return JSONResponse(
content={"message": "Role must be 'admin' or 'viewer'"}, status_code=400
content={"message": f"Role must be one of: {', '.join(config_roles)}"},
status_code=400,
)
User.set_by_id(username, {User.role: body.role})
return JSONResponse(content={"success": True})
async def require_camera_access(
camera_name: Optional[str] = None,
request: Request = None,
):
"""Dependency to enforce camera access based on user role."""
if camera_name is None:
return # For lists, filter later
current_user = await get_current_user(request)
if isinstance(current_user, JSONResponse):
return current_user
role = current_user["role"]
all_camera_names = set(request.app.frigate_config.cameras.keys())
roles_dict = request.app.frigate_config.auth.roles
allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names)
# Admin or full access bypasses
if role == "admin" or not roles_dict.get(role):
return
if camera_name not in allowed_cameras:
raise HTTPException(
status_code=403,
detail=f"Access denied to camera '{camera_name}'. Allowed: {allowed_cameras}",
)
async def get_allowed_cameras_for_filter(request: Request):
"""Dependency to get allowed_cameras for filtering lists."""
current_user = await get_current_user(request)
if isinstance(current_user, JSONResponse):
return [] # Unauthorized: no cameras
role = current_user["role"]
all_camera_names = set(request.app.frigate_config.cameras.keys())
roles_dict = request.app.frigate_config.auth.roles
return User.get_allowed_cameras(role, roles_dict, all_camera_names)

View File

@ -8,6 +8,7 @@ import random
import string
from functools import reduce
from pathlib import Path
from typing import List
from urllib.parse import unquote
import cv2
@ -19,7 +20,11 @@ from pathvalidate import sanitize_filename
from peewee import JOIN, DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_role
from frigate.api.auth import (
get_allowed_cameras_for_filter,
require_camera_access,
require_role,
)
from frigate.api.defs.query.events_query_parameters import (
DEFAULT_TIME_RANGE,
EventsQueryParams,
@ -61,7 +66,10 @@ router = APIRouter(tags=[Tags.events])
@router.get("/events", response_model=list[EventResponse])
def events(params: EventsQueryParams = Depends()):
def events(
params: EventsQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
camera = params.camera
cameras = params.cameras
@ -135,7 +143,13 @@ def events(params: EventsQueryParams = Depends()):
clauses.append((Event.camera == camera))
if cameras != "all":
camera_list = cameras.split(",")
requested = set(cameras.split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content=[])
camera_list = list(filtered)
else:
camera_list = allowed_cameras
clauses.append((Event.camera << camera_list))
if labels != "all":
@ -321,9 +335,17 @@ def events(params: EventsQueryParams = Depends()):
@router.get("/events/explore", response_model=list[EventResponse])
def events_explore(limit: int = 10):
def events_explore(
limit: int = 10,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
# get distinct labels for all events
distinct_labels = Event.select(Event.label).distinct().order_by(Event.label)
distinct_labels = (
Event.select(Event.label)
.where(Event.camera << allowed_cameras)
.distinct()
.order_by(Event.label)
)
label_counts = {}
@ -334,14 +356,18 @@ def events_explore(limit: int = 10):
# get most recent events for this label
label_events = (
Event.select()
.where(Event.label == label)
.where((Event.label == label) & (Event.camera << allowed_cameras))
.order_by(Event.start_time.desc())
.limit(limit)
.iterator()
)
# count total events for this label
label_counts[label] = Event.select().where(Event.label == label).count()
label_counts[label] = (
Event.select()
.where((Event.label == label) & (Event.camera << allowed_cameras))
.count()
)
yield from label_events
@ -394,7 +420,7 @@ def events_explore(limit: int = 10):
@router.get("/event_ids", response_model=list[EventResponse])
def event_ids(ids: str):
async def event_ids(ids: str, request: Request):
ids = ids.split(",")
if not ids:
@ -403,6 +429,16 @@ def event_ids(ids: str):
status_code=400,
)
for event_id in ids:
try:
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
return JSONResponse(
content=({"success": False, "message": f"Event {event_id} not found"}),
status_code=404,
)
try:
events = Event.select().where(Event.id << ids).dicts().iterator()
return JSONResponse(list(events))
@ -413,7 +449,11 @@ def event_ids(ids: str):
@router.get("/events/search")
def events_search(request: Request, params: EventsSearchQueryParams = Depends()):
def events_search(
request: Request,
params: EventsSearchQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
query = params.query
search_type = params.search_type
include_thumbnails = params.include_thumbnails
@ -486,7 +526,13 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
event_filters = []
if cameras != "all":
event_filters.append((Event.camera << cameras.split(",")))
requested = set(cameras.split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content=[])
event_filters.append((Event.camera << list(filtered)))
else:
event_filters.append((Event.camera << allowed_cameras))
if labels != "all":
event_filters.append((Event.label << labels.split(",")))
@ -739,7 +785,10 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
@router.get("/events/summary")
def events_summary(params: EventsSummaryQueryParams = Depends()):
def events_summary(
params: EventsSummaryQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
tz_name = params.timezone
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
has_clip = params.has_clip
@ -771,7 +820,7 @@ def events_summary(params: EventsSummaryQueryParams = Depends()):
Event.zones,
fn.COUNT(Event.id).alias("count"),
)
.where(reduce(operator.and_, clauses))
.where(reduce(operator.and_, clauses) & (Event.camera << allowed_cameras))
.group_by(
Event.camera,
Event.label,
@ -786,9 +835,11 @@ def events_summary(params: EventsSummaryQueryParams = Depends()):
@router.get("/events/{event_id}", response_model=EventResponse)
def event(event_id: str):
async def event(event_id: str, request: Request):
try:
return model_to_dict(Event.get(Event.id == event_id))
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
return model_to_dict(event)
except DoesNotExist:
return JSONResponse(content="Event not found", status_code=404)
@ -817,7 +868,7 @@ def set_retain(event_id: str):
@router.post("/events/{event_id}/plus", response_model=EventUploadPlusResponse)
def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None):
async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None):
if not request.app.frigate_config.plus_api.is_active():
message = "PLUS_API_KEY environment variable is not set"
logger.error(message)
@ -835,6 +886,7 @@ def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None):
try:
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
message = f"Event {event_id} not found"
logger.error(message)
@ -929,7 +981,7 @@ def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None):
@router.put("/events/{event_id}/false_positive", response_model=EventUploadPlusResponse)
def false_positive(request: Request, event_id: str):
async def false_positive(request: Request, event_id: str):
if not request.app.frigate_config.plus_api.is_active():
message = "PLUS_API_KEY environment variable is not set"
logger.error(message)
@ -945,6 +997,7 @@ def false_positive(request: Request, event_id: str):
try:
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
message = f"Event {event_id} not found"
logger.error(message)
@ -1022,9 +1075,10 @@ def false_positive(request: Request, event_id: str):
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def delete_retain(event_id: str):
async def delete_retain(event_id: str, request: Request):
try:
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}),
@ -1045,13 +1099,14 @@ def delete_retain(event_id: str):
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def set_sub_label(
async def set_sub_label(
request: Request,
event_id: str,
body: EventsSubLabelBody,
):
try:
event: Event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
event = None
@ -1099,13 +1154,14 @@ def set_sub_label(
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def set_plate(
async def set_plate(
request: Request,
event_id: str,
body: EventsLPRBody,
):
try:
event: Event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
event = None
@ -1154,13 +1210,14 @@ def set_plate(
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def set_description(
async def set_description(
request: Request,
event_id: str,
body: EventsDescriptionBody,
):
try:
event: Event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}),
@ -1205,11 +1262,12 @@ def set_description(
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def regenerate_description(
async def regenerate_description(
request: Request, event_id: str, params: RegenerateQueryParameters = Depends()
):
try:
event: Event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}),
@ -1280,9 +1338,10 @@ def generate_description_embedding(
)
def delete_single_event(event_id: str, request: Request) -> dict:
async def delete_single_event(event_id: str, request: Request) -> dict:
try:
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
return {"success": False, "message": f"Event {event_id} not found"}
@ -1312,8 +1371,8 @@ def delete_single_event(event_id: str, request: Request) -> dict:
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def delete_event(request: Request, event_id: str):
result = delete_single_event(event_id, request)
async def delete_event(request: Request, event_id: str):
result = await delete_single_event(event_id, request)
status_code = 200 if result["success"] else 404
return JSONResponse(content=result, status_code=status_code)
@ -1323,7 +1382,7 @@ def delete_event(request: Request, event_id: str):
response_model=EventMultiDeleteResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def delete_events(request: Request, body: EventsDeleteBody):
async def delete_events(request: Request, body: EventsDeleteBody):
if not body.event_ids:
return JSONResponse(
content=({"success": False, "message": "No event IDs provided."}),
@ -1334,7 +1393,7 @@ def delete_events(request: Request, body: EventsDeleteBody):
not_found_events = []
for event_id in body.event_ids:
result = delete_single_event(event_id, request)
result = await delete_single_event(event_id, request)
if result["success"]:
deleted_events.append(event_id)
else:
@ -1410,8 +1469,10 @@ def create_event(
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def end_event(request: Request, event_id: str, body: EventsEndBody):
async def end_event(request: Request, event_id: str, body: EventsEndBody):
try:
event: Event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
end_time = body.end_time or datetime.datetime.now().timestamp()
request.app.event_metadata_updater.publish(
(event_id, end_time), EventMetadataTypeEnum.manual_event_end.value
@ -1438,7 +1499,7 @@ def end_event(request: Request, event_id: str, body: EventsEndBody):
def create_trigger_embedding(
request: Request,
body: TriggerEmbeddingBody,
camera: str,
camera_name: str,
name: str,
):
try:
@ -1454,13 +1515,13 @@ def create_trigger_embedding(
# Check if trigger already exists
if (
Trigger.select()
.where(Trigger.camera == camera, Trigger.name == name)
.where(Trigger.camera == camera_name, Trigger.name == name)
.exists()
):
return JSONResponse(
content={
"success": False,
"message": f"Trigger {camera}:{name} already exists",
"message": f"Trigger {camera_name}:{name} already exists",
},
status_code=400,
)
@ -1530,28 +1591,29 @@ def create_trigger_embedding(
# Save image to the triggers directory
try:
os.makedirs(
os.path.join(TRIGGER_DIR, sanitize_filename(camera)), exist_ok=True
os.path.join(TRIGGER_DIR, sanitize_filename(camera_name)),
exist_ok=True,
)
with open(
os.path.join(
TRIGGER_DIR,
sanitize_filename(camera),
sanitize_filename(camera_name),
f"{sanitize_filename(body.data)}.webp",
),
"wb",
) as f:
f.write(thumbnail)
logger.debug(
f"Writing thumbnail for trigger with data {body.data} in {camera}."
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
)
except Exception as e:
logger.error(e.with_traceback())
logger.error(
f"Failed to write thumbnail for trigger with data {body.data} in {camera}"
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
)
Trigger.create(
camera=camera,
camera=camera_name,
name=name,
type=body.type,
data=body.data,
@ -1565,7 +1627,7 @@ def create_trigger_embedding(
return JSONResponse(
content={
"success": True,
"message": f"Trigger created successfully for {camera}:{name}",
"message": f"Trigger created successfully for {camera_name}:{name}",
},
status_code=200,
)
@ -1582,13 +1644,13 @@ def create_trigger_embedding(
@router.put(
"/trigger/embedding/{camera}/{name}",
"/trigger/embedding/{camera_name}/{name}",
response_model=dict,
dependencies=[Depends(require_role(["admin"]))],
)
def update_trigger_embedding(
request: Request,
camera: str,
camera_name: str,
name: str,
body: TriggerEmbeddingBody,
):
@ -1609,7 +1671,9 @@ def update_trigger_embedding(
embedding = context.generate_description_embedding(body.data)
elif body.type == "thumbnail":
webp_file = sanitize_filename(body.data) + ".webp"
webp_path = os.path.join(TRIGGER_DIR, sanitize_filename(camera), webp_file)
webp_path = os.path.join(
TRIGGER_DIR, sanitize_filename(camera_name), webp_file
)
try:
event: Event = Event.get(Event.id == body.data)
@ -1656,7 +1720,9 @@ def update_trigger_embedding(
)
# Check if trigger exists for upsert
trigger = Trigger.get_or_none(Trigger.camera == camera, Trigger.name == name)
trigger = Trigger.get_or_none(
Trigger.camera == camera_name, Trigger.name == name
)
if trigger:
# Update existing trigger
@ -1665,17 +1731,17 @@ def update_trigger_embedding(
os.remove(
os.path.join(
TRIGGER_DIR,
sanitize_filename(camera),
sanitize_filename(camera_name),
f"{trigger.data}.webp",
)
)
logger.debug(
f"Deleted thumbnail for trigger with data {trigger.data} in {camera}."
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
)
except Exception as e:
logger.error(e.with_traceback())
logger.error(
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera}"
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
)
Trigger.update(
@ -1685,11 +1751,11 @@ def update_trigger_embedding(
threshold=body.threshold,
triggering_event_id="",
last_triggered=None,
).where(Trigger.camera == camera, Trigger.name == name).execute()
).where(Trigger.camera == camera_name, Trigger.name == name).execute()
else:
# Create new trigger (for rename case)
Trigger.create(
camera=camera,
camera=camera_name,
name=name,
type=body.type,
data=body.data,
@ -1703,7 +1769,7 @@ def update_trigger_embedding(
if body.type == "thumbnail":
# Save image to the triggers directory
try:
camera_path = os.path.join(TRIGGER_DIR, sanitize_filename(camera))
camera_path = os.path.join(TRIGGER_DIR, sanitize_filename(camera_name))
os.makedirs(camera_path, exist_ok=True)
with open(
os.path.join(camera_path, f"{sanitize_filename(body.data)}.webp"),
@ -1711,18 +1777,18 @@ def update_trigger_embedding(
) as f:
f.write(thumbnail)
logger.debug(
f"Writing thumbnail for trigger with data {body.data} in {camera}."
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
)
except Exception as e:
logger.error(e.with_traceback())
logger.error(
f"Failed to write thumbnail for trigger with data {body.data} in {camera}"
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
)
return JSONResponse(
content={
"success": True,
"message": f"Trigger updated successfully for {camera}:{name}",
"message": f"Trigger updated successfully for {camera_name}:{name}",
},
status_code=200,
)
@ -1739,36 +1805,38 @@ def update_trigger_embedding(
@router.delete(
"/trigger/embedding/{camera}/{name}",
"/trigger/embedding/{camera_name}/{name}",
response_model=dict,
dependencies=[Depends(require_role(["admin"]))],
)
def delete_trigger_embedding(
request: Request,
camera: str,
camera_name: str,
name: str,
):
try:
trigger = Trigger.get_or_none(Trigger.camera == camera, Trigger.name == name)
trigger = Trigger.get_or_none(
Trigger.camera == camera_name, Trigger.name == name
)
if trigger is None:
return JSONResponse(
content={
"success": False,
"message": f"Trigger {camera}:{name} not found",
"message": f"Trigger {camera_name}:{name} not found",
},
status_code=500,
)
deleted = (
Trigger.delete()
.where(Trigger.camera == camera, Trigger.name == name)
.where(Trigger.camera == camera_name, Trigger.name == name)
.execute()
)
if deleted == 0:
return JSONResponse(
content={
"success": False,
"message": f"Error deleting trigger {camera}:{name}",
"message": f"Error deleting trigger {camera_name}:{name}",
},
status_code=401,
)
@ -1776,22 +1844,22 @@ def delete_trigger_embedding(
try:
os.remove(
os.path.join(
TRIGGER_DIR, sanitize_filename(camera), f"{trigger.data}.webp"
TRIGGER_DIR, sanitize_filename(camera_name), f"{trigger.data}.webp"
)
)
logger.debug(
f"Deleted thumbnail for trigger with data {trigger.data} in {camera}."
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
)
except Exception as e:
logger.error(e.with_traceback())
logger.error(
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera}"
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
)
return JSONResponse(
content={
"success": True,
"message": f"Trigger deleted successfully for {camera}:{name}",
"message": f"Trigger deleted successfully for {camera_name}:{name}",
},
status_code=200,
)

View File

@ -4,6 +4,7 @@ import logging
import random
import string
from pathlib import Path
from typing import List
import psutil
from fastapi import APIRouter, Depends, Request
@ -11,7 +12,11 @@ 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.auth import (
get_allowed_cameras_for_filter,
require_camera_access,
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
@ -30,12 +35,23 @@ router = APIRouter(tags=[Tags.export])
@router.get("/exports")
def get_exports():
exports = Export.select().order_by(Export.date.desc()).dicts().iterator()
def get_exports(
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
exports = (
Export.select()
.where(Export.camera << allowed_cameras)
.order_by(Export.date.desc())
.dicts()
.iterator()
)
return JSONResponse(content=[e for e in exports])
@router.post("/export/{camera_name}/start/{start_time}/end/{end_time}")
@router.post(
"/export/{camera_name}/start/{start_time}/end/{end_time}",
dependencies=[Depends(require_camera_access)],
)
def export_recording(
request: Request,
camera_name: str,
@ -134,9 +150,10 @@ def export_recording(
@router.patch(
"/export/{event_id}/rename", dependencies=[Depends(require_role(["admin"]))]
)
def export_rename(event_id: str, body: ExportRenameBody):
async def export_rename(event_id: str, body: ExportRenameBody, request: Request):
try:
export: Export = Export.get(Export.id == event_id)
await require_camera_access(export.camera, request=request)
except DoesNotExist:
return JSONResponse(
content=(
@ -162,9 +179,10 @@ def export_rename(event_id: str, body: ExportRenameBody):
@router.delete("/export/{event_id}", dependencies=[Depends(require_role(["admin"]))])
def export_delete(event_id: str):
async def export_delete(event_id: str, request: Request):
try:
export: Export = Export.get(Export.id == event_id)
await require_camera_access(export.camera, request=request)
except DoesNotExist:
return JSONResponse(
content=(
@ -215,9 +233,11 @@ def export_delete(event_id: str):
@router.get("/exports/{export_id}")
def get_export(export_id: str):
async def get_export(export_id: str, request: Request):
try:
return JSONResponse(content=model_to_dict(Export.get(Export.id == export_id)))
export = Export.get(Export.id == export_id)
await require_camera_access(export.camera, request=request)
return JSONResponse(content=model_to_dict(export))
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export not found"},

View File

@ -10,19 +10,19 @@ import time
from datetime import datetime, timedelta, timezone
from functools import reduce
from pathlib import Path as FilePath
from typing import Any
from typing import Any, List
from urllib.parse import unquote
import cv2
import numpy as np
import pytz
from fastapi import APIRouter, Path, Query, Request, Response
from fastapi.params import Depends
from fastapi import APIRouter, Depends, Path, Query, Request, Response
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pathvalidate import sanitize_filename
from peewee import DoesNotExist, fn, operator
from tzlocal import get_localzone_name
from frigate.api.auth import get_allowed_cameras_for_filter, require_camera_access
from frigate.api.defs.query.media_query_parameters import (
Extension,
MediaEventsSnapshotQueryParams,
@ -50,12 +50,11 @@ from frigate.util.path import get_event_thumbnail_bytes
logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.media])
@router.get("/{camera_name}")
def mjpeg_feed(
@router.get("/{camera_name}", dependencies=[Depends(require_camera_access)])
async def mjpeg_feed(
request: Request,
camera_name: str,
params: MediaMjpegFeedQueryParams = Depends(),
@ -111,7 +110,7 @@ def imagestream(
)
@router.get("/{camera_name}/ptz/info")
@router.get("/{camera_name}/ptz/info", dependencies=[Depends(require_camera_access)])
async def camera_ptz_info(request: Request, camera_name: str):
if camera_name in request.app.frigate_config.cameras:
# Schedule get_camera_info in the OnvifController's event loop
@ -127,8 +126,10 @@ async def camera_ptz_info(request: Request, camera_name: str):
)
@router.get("/{camera_name}/latest.{extension}")
def latest_frame(
@router.get(
"/{camera_name}/latest.{extension}", dependencies=[Depends(require_camera_access)]
)
async def latest_frame(
request: Request,
camera_name: str,
extension: Extension,
@ -236,8 +237,11 @@ def latest_frame(
)
@router.get("/{camera_name}/recordings/{frame_time}/snapshot.{format}")
def get_snapshot_from_recording(
@router.get(
"/{camera_name}/recordings/{frame_time}/snapshot.{format}",
dependencies=[Depends(require_camera_access)],
)
async def get_snapshot_from_recording(
request: Request,
camera_name: str,
frame_time: float,
@ -323,8 +327,10 @@ def get_snapshot_from_recording(
)
@router.post("/{camera_name}/plus/{frame_time}")
def submit_recording_snapshot_to_plus(
@router.post(
"/{camera_name}/plus/{frame_time}", dependencies=[Depends(require_camera_access)]
)
async def submit_recording_snapshot_to_plus(
request: Request, camera_name: str, frame_time: str
):
if camera_name not in request.app.frigate_config.cameras:
@ -412,11 +418,23 @@ def get_recordings_storage_usage(request: Request):
@router.get("/recordings/summary")
def all_recordings_summary(params: MediaRecordingsSummaryQueryParams = Depends()):
def all_recordings_summary(
request: Request,
params: MediaRecordingsSummaryQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
"""Returns true/false by day indicating if recordings exist"""
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone)
cameras = params.cameras
if cameras != "all":
requested = set(unquote(cameras).split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content={})
cameras = ",".join(filtered)
else:
cameras = allowed_cameras
query = (
Recordings.select(
@ -445,7 +463,7 @@ def all_recordings_summary(params: MediaRecordingsSummaryQueryParams = Depends()
)
if cameras != "all":
query = query.where(Recordings.camera << cameras.split(","))
query = query.where(Recordings.camera << cameras)
recording_days = query.namedtuples()
days = {day.day: True for day in recording_days}
@ -453,8 +471,10 @@ def all_recordings_summary(params: MediaRecordingsSummaryQueryParams = Depends()
return JSONResponse(content=days)
@router.get("/{camera_name}/recordings/summary")
def recordings_summary(camera_name: str, timezone: str = "utc"):
@router.get(
"/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)]
)
async def recordings_summary(camera_name: str, timezone: str = "utc"):
"""Returns hourly summary for recordings of given camera"""
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(timezone)
recording_groups = (
@ -515,8 +535,8 @@ def recordings_summary(camera_name: str, timezone: str = "utc"):
return JSONResponse(content=list(days.values()))
@router.get("/{camera_name}/recordings")
def recordings(
@router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)])
async def recordings(
camera_name: str,
after: float = (datetime.now() - timedelta(hours=1)).timestamp(),
before: float = datetime.now().timestamp(),
@ -546,9 +566,22 @@ def recordings(
@router.get("/recordings/unavailable", response_model=list[dict])
def no_recordings(params: MediaRecordingsAvailabilityQueryParams = Depends()):
async def no_recordings(
request: Request,
params: MediaRecordingsAvailabilityQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
"""Get time ranges with no recordings."""
cameras = params.cameras
if cameras != "all":
requested = set(unquote(cameras).split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content=[])
cameras = ",".join(filtered)
else:
cameras = allowed_cameras
before = params.before or datetime.datetime.now().timestamp()
after = (
params.after
@ -560,6 +593,8 @@ def no_recordings(params: MediaRecordingsAvailabilityQueryParams = Depends()):
if cameras != "all":
camera_list = cameras.split(",")
clauses.append((Recordings.camera << camera_list))
else:
camera_list = allowed_cameras
# Get recording start times
data: list[Recordings] = (
@ -607,9 +642,10 @@ def no_recordings(params: MediaRecordingsAvailabilityQueryParams = Depends()):
@router.get(
"/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4",
dependencies=[Depends(require_camera_access)],
description="For iOS devices, use the master.m3u8 HLS link instead of clip.mp4. Safari does not reliably process progressive mp4 files.",
)
def recording_clip(
async def recording_clip(
request: Request,
camera_name: str,
start_ts: float,
@ -705,9 +741,10 @@ def recording_clip(
@router.get(
"/vod/{camera_name}/start/{start_ts}/end/{end_ts}",
dependencies=[Depends(require_camera_access)],
description="Returns an HLS playlist for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.",
)
def vod_ts(camera_name: str, start_ts: float, end_ts: float):
async def vod_ts(camera_name: str, start_ts: float, end_ts: float):
recordings = (
Recordings.select(
Recordings.path,
@ -782,6 +819,7 @@ def vod_ts(camera_name: str, start_ts: float, end_ts: float):
@router.get(
"/vod/{year_month}/{day}/{hour}/{camera_name}",
dependencies=[Depends(require_camera_access)],
description="Returns an HLS playlist for the specified date-time on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.",
)
def vod_hour_no_timezone(year_month: str, day: int, hour: int, camera_name: str):
@ -793,6 +831,7 @@ def vod_hour_no_timezone(year_month: str, day: int, hour: int, camera_name: str)
@router.get(
"/vod/{year_month}/{day}/{hour}/{camera_name}/{tz_name}",
dependencies=[Depends(require_camera_access)],
description="Returns an HLS playlist for the specified date-time (with timezone) on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.",
)
def vod_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: str):
@ -812,7 +851,8 @@ def vod_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: st
"/vod/event/{event_id}",
description="Returns an HLS playlist for the specified object. Append /master.m3u8 or /index.m3u8 for HLS playback.",
)
def vod_event(
async def vod_event(
request: Request,
event_id: str,
padding: int = Query(0, description="Padding to apply to the vod."),
):
@ -828,15 +868,7 @@ def vod_event(
status_code=404,
)
if not event.has_clip:
logger.error(f"Event does not have recordings: {event_id}")
return JSONResponse(
content={
"success": False,
"message": "Recordings not available.",
},
status_code=404,
)
await require_camera_access(event.camera, request=request)
end_ts = (
datetime.now().timestamp()
@ -861,7 +893,7 @@ def vod_event(
"/events/{event_id}/snapshot.jpg",
description="Returns a snapshot image for the specified object id. NOTE: The query params only take affect while the event is in-progress. Once the event has ended the snapshot configuration is used.",
)
def event_snapshot(
async def event_snapshot(
request: Request,
event_id: str,
params: MediaEventsSnapshotQueryParams = Depends(),
@ -871,6 +903,7 @@ def event_snapshot(
try:
event = Event.get(Event.id == event_id, Event.end_time != None)
event_complete = True
await require_camera_access(event.camera, request=request)
if not event.has_snapshot:
return JSONResponse(
content={"success": False, "message": "Snapshot not available"},
@ -899,6 +932,7 @@ def event_snapshot(
height=params.height,
quality=params.quality,
)
await require_camera_access(camera_state.name, request=request)
except Exception:
return JSONResponse(
content={"success": False, "message": "Ongoing event not found"},
@ -932,7 +966,7 @@ def event_snapshot(
@router.get("/events/{event_id}/thumbnail.{extension}")
def event_thumbnail(
async def event_thumbnail(
request: Request,
event_id: str,
extension: Extension,
@ -945,6 +979,7 @@ def event_thumbnail(
event_complete = False
try:
event: Event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
if event.end_time is not None:
event_complete = True
@ -1007,7 +1042,7 @@ def event_thumbnail(
)
@router.get("/{camera_name}/grid.jpg")
@router.get("/{camera_name}/grid.jpg", dependencies=[Depends(require_camera_access)])
def grid_snapshot(
request: Request, camera_name: str, color: str = "green", font_scale: float = 0.5
):
@ -1254,7 +1289,10 @@ def event_preview(request: Request, event_id: str):
return preview_gif(request, event.camera, start_ts, end_ts)
@router.get("/{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif")
@router.get(
"/{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif",
dependencies=[Depends(require_camera_access)],
)
def preview_gif(
request: Request,
camera_name: str,
@ -1410,7 +1448,10 @@ def preview_gif(
)
@router.get("/{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4")
@router.get(
"/{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4",
dependencies=[Depends(require_camera_access)],
)
def preview_mp4(
request: Request,
camera_name: str,
@ -1650,8 +1691,13 @@ def preview_thumbnail(file_name: str):
####################### dynamic routes ###########################
@router.get("/{camera_name}/{label}/best.jpg")
@router.get("/{camera_name}/{label}/thumbnail.jpg")
@router.get(
"/{camera_name}/{label}/best.jpg", dependencies=[Depends(require_camera_access)]
)
@router.get(
"/{camera_name}/{label}/thumbnail.jpg",
dependencies=[Depends(require_camera_access)],
)
def label_thumbnail(request: Request, camera_name: str, label: str):
label = unquote(label)
event_query = Event.select(fn.MAX(Event.id)).where(Event.camera == camera_name)
@ -1673,7 +1719,9 @@ def label_thumbnail(request: Request, camera_name: str, label: str):
)
@router.get("/{camera_name}/{label}/clip.mp4")
@router.get(
"/{camera_name}/{label}/clip.mp4", dependencies=[Depends(require_camera_access)]
)
def label_clip(request: Request, camera_name: str, label: str):
label = unquote(label)
event_query = Event.select(fn.MAX(Event.id)).where(
@ -1692,7 +1740,9 @@ def label_clip(request: Request, camera_name: str, label: str):
)
@router.get("/{camera_name}/{label}/snapshot.jpg")
@router.get(
"/{camera_name}/{label}/snapshot.jpg", dependencies=[Depends(require_camera_access)]
)
def label_snapshot(request: Request, camera_name: str, label: str):
"""Returns the snapshot image from the latest event for the given camera and label combo"""
label = unquote(label)

View File

@ -5,9 +5,10 @@ import os
from datetime import datetime, timedelta, timezone
import pytz
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from frigate.api.auth import require_camera_access
from frigate.api.defs.tags import Tags
from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE
from frigate.models import Previews
@ -18,7 +19,10 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.preview])
@router.get("/preview/{camera_name}/start/{start_ts}/end/{end_ts}")
@router.get(
"/preview/{camera_name}/start/{start_ts}/end/{end_ts}",
dependencies=[Depends(require_camera_access)],
)
def preview_ts(camera_name: str, start_ts: float, end_ts: float):
"""Get all mp4 previews relevant for time period."""
if camera_name != "all":
@ -71,7 +75,10 @@ def preview_ts(camera_name: str, start_ts: float, end_ts: float):
return JSONResponse(content=clips, status_code=200)
@router.get("/preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}")
@router.get(
"/preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}",
dependencies=[Depends(require_camera_access)],
)
def preview_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: str):
"""Get all mp4 previews relevant for time period given the timezone"""
parts = year_month.split("-")
@ -86,7 +93,10 @@ def preview_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name
return preview_ts(camera_name, start_ts, end_ts)
@router.get("/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames")
@router.get(
"/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames",
dependencies=[Depends(require_camera_access)],
)
def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: float):
"""Get list of cached preview frames"""
preview_dir = os.path.join(CACHE_DIR, "preview_frames")

View File

@ -4,6 +4,7 @@ import datetime
import logging
from functools import reduce
from pathlib import Path
from typing import List
import pandas as pd
from fastapi import APIRouter, Request
@ -12,7 +13,12 @@ from fastapi.responses import JSONResponse
from peewee import Case, DoesNotExist, IntegrityError, fn, operator
from playhouse.shortcuts import model_to_dict
from frigate.api.auth import get_current_user, require_role
from frigate.api.auth import (
get_allowed_cameras_for_filter,
get_current_user,
require_camera_access,
require_role,
)
from frigate.api.defs.query.review_query_parameters import (
ReviewActivityMotionQueryParams,
ReviewQueryParams,
@ -41,6 +47,7 @@ router = APIRouter(tags=[Tags.review])
async def review(
params: ReviewQueryParams = Depends(),
current_user: dict = Depends(get_current_user),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
if isinstance(current_user, JSONResponse):
return current_user
@ -65,7 +72,13 @@ async def review(
]
if cameras != "all":
camera_list = cameras.split(",")
requested = set(cameras.split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content=[])
camera_list = list(filtered)
else:
camera_list = allowed_cameras
clauses.append((ReviewSegment.camera << camera_list))
if labels != "all":
@ -140,7 +153,7 @@ async def review(
@router.get("/review_ids", response_model=list[ReviewSegmentResponse])
def review_ids(ids: str):
async def review_ids(request: Request, ids: str):
ids = ids.split(",")
if not ids:
@ -149,6 +162,18 @@ def review_ids(ids: str):
status_code=400,
)
for review_id in ids:
try:
review = ReviewSegment.get(ReviewSegment.id == review_id)
await require_camera_access(review.camera, request=request)
except DoesNotExist:
return JSONResponse(
content=(
{"success": False, "message": f"Review {review_id} not found"}
),
status_code=404,
)
try:
reviews = (
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
@ -165,6 +190,7 @@ def review_ids(ids: str):
async def review_summary(
params: ReviewSummaryQueryParams = Depends(),
current_user: dict = Depends(get_current_user),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
if isinstance(current_user, JSONResponse):
return current_user
@ -181,7 +207,13 @@ async def review_summary(
clauses = [(ReviewSegment.start_time > day_ago)]
if cameras != "all":
camera_list = cameras.split(",")
requested = set(cameras.split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content={})
camera_list = list(filtered)
else:
camera_list = allowed_cameras
clauses.append((ReviewSegment.camera << camera_list))
if labels != "all":
@ -276,7 +308,13 @@ async def review_summary(
clauses = []
if cameras != "all":
camera_list = cameras.split(",")
requested = set(cameras.split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content={})
camera_list = list(filtered)
else:
camera_list = allowed_cameras
clauses.append((ReviewSegment.camera << camera_list))
if labels != "all":
@ -380,6 +418,7 @@ async def review_summary(
@router.post("/reviews/viewed", response_model=GenericResponse)
async def set_multiple_reviewed(
request: Request,
body: ReviewModifyMultipleBody,
current_user: dict = Depends(get_current_user),
):
@ -390,6 +429,8 @@ async def set_multiple_reviewed(
for review_id in body.ids:
try:
review = ReviewSegment.get(ReviewSegment.id == review_id)
await require_camera_access(review.camera, request=request)
review_status = UserReviewStatus.get(
UserReviewStatus.user_id == user_id,
UserReviewStatus.review_segment == review_id,
@ -471,7 +512,10 @@ def delete_reviews(body: ReviewModifyMultipleBody):
@router.get(
"/review/activity/motion", response_model=list[ReviewActivityMotionResponse]
)
def motion_activity(params: ReviewActivityMotionQueryParams = Depends()):
def motion_activity(
params: ReviewActivityMotionQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
"""Get motion and audio activity."""
cameras = params.cameras
before = params.before or datetime.datetime.now().timestamp()
@ -486,8 +530,14 @@ def motion_activity(params: ReviewActivityMotionQueryParams = Depends()):
clauses.append((Recordings.motion > 0))
if cameras != "all":
camera_list = cameras.split(",")
requested = set(cameras.split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content=[])
camera_list = list(filtered)
clauses.append((Recordings.camera << camera_list))
else:
clauses.append((Recordings.camera << allowed_cameras))
data: list[Recordings] = (
Recordings.select(
@ -545,15 +595,13 @@ def motion_activity(params: ReviewActivityMotionQueryParams = Depends()):
@router.get("/review/event/{event_id}", response_model=ReviewSegmentResponse)
def get_review_from_event(event_id: str):
async def get_review_from_event(request: Request, event_id: str):
try:
return JSONResponse(
model_to_dict(
ReviewSegment.get(
review = ReviewSegment.get(
ReviewSegment.data["detections"].cast("text") % f'*"{event_id}"*'
)
)
)
await require_camera_access(review.camera, request=request)
return JSONResponse(model_to_dict(review))
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Review item not found"},
@ -562,11 +610,11 @@ def get_review_from_event(event_id: str):
@router.get("/review/{review_id}", response_model=ReviewSegmentResponse)
def get_review(review_id: str):
async def get_review(request: Request, review_id: str):
try:
return JSONResponse(
content=model_to_dict(ReviewSegment.get(ReviewSegment.id == review_id))
)
review = ReviewSegment.get(ReviewSegment.id == review_id)
await require_camera_access(review.camera, request=request)
return JSONResponse(content=model_to_dict(review))
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Review item not found"},

View File

@ -1,6 +1,6 @@
from typing import Optional
from typing import Dict, List, Optional
from pydantic import Field
from pydantic import Field, field_validator, model_validator
from .base import FrigateBaseModel
@ -34,3 +34,41 @@ class AuthConfig(FrigateBaseModel):
)
# As of Feb 2023, OWASP recommends 600000 iterations for PBKDF2-SHA256
hash_iterations: int = Field(default=600000, title="Password hash iterations")
roles: Dict[str, List[str]] = Field(
default_factory=dict,
title="Role to camera mappings. Empty list grants access to all cameras.",
)
@field_validator("roles")
@classmethod
def validate_roles(cls, v: Dict[str, List[str]]) -> Dict[str, List[str]]:
# Ensure role names are valid (alphanumeric with underscores)
for role in v.keys():
if not role.replace("_", "").isalnum():
raise ValueError(
f"Invalid role name '{role}'. Must be alphanumeric with underscores."
)
# Ensure 'admin' and 'viewer' are not used as custom role names
reserved_roles = {"admin", "viewer"}
if v.keys() & reserved_roles:
raise ValueError(
f"Reserved roles {reserved_roles} cannot be used as custom roles."
)
# Ensure no role has an empty camera list
for role, allowed_cameras in v.items():
if not allowed_cameras:
raise ValueError(
f"Role '{role}' has no cameras assigned. Custom roles must have at least one camera."
)
return v
@model_validator(mode="after")
def ensure_default_roles(self):
# Ensure admin and viewer are never overridden
self.roles["admin"] = []
self.roles["viewer"] = []
return self

View File

@ -719,6 +719,18 @@ class FrigateConfig(FrigateBaseModel):
"Frigate+ is configured but clean snapshots are not enabled, submissions to Frigate+ will not be possible./"
)
# Validate auth roles against cameras
camera_names = set(self.cameras.keys())
for role, allowed_cameras in self.auth.roles.items():
invalid_cameras = [
cam for cam in allowed_cameras if cam not in camera_names
]
if invalid_cameras:
logger.warning(
f"Role '{role}' references non-existent cameras: {invalid_cameras}. "
)
return self
@field_validator("cameras")

View File

@ -135,6 +135,18 @@ class User(Model):
password_hash = CharField(null=False, max_length=120)
notification_tokens = JSONField()
@classmethod
def get_allowed_cameras(
cls, role: str, roles_dict: dict[str, list[str]], all_camera_names: set[str]
) -> list[str]:
if role not in roles_dict:
return [] # Invalid role grants no access
allowed = roles_dict[role]
if not allowed: # Empty list means all cameras
return list(all_camera_names)
return [cam for cam in allowed if cam in all_camera_names]
class Trigger(Model):
camera = CharField(max_length=20)

View File

@ -112,7 +112,7 @@ class BaseTestHttp(unittest.TestCase):
except OSError:
pass
def create_app(self, stats=None):
def create_app(self, stats=None, event_metadata_publisher=None):
return create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
@ -121,7 +121,7 @@ class BaseTestHttp(unittest.TestCase):
None,
None,
stats,
None,
event_metadata_publisher,
None,
)
@ -134,12 +134,13 @@ class BaseTestHttp(unittest.TestCase):
top_score: int = 100,
score: int = 0,
data: Json = {},
camera: str = "front_door",
) -> Event:
"""Inserts a basic event model with a given id."""
return Event.insert(
id=id,
label="Mock",
camera="front_door",
camera=camera,
start_time=start_time,
end_time=end_time,
top_score=top_score,
@ -158,15 +159,23 @@ class BaseTestHttp(unittest.TestCase):
def insert_mock_review_segment(
self,
id: str,
start_time: float = datetime.datetime.now().timestamp(),
end_time: float = datetime.datetime.now().timestamp() + 20,
start_time: float | None = None,
end_time: float | None = None,
severity: SeverityEnum = SeverityEnum.alert,
data: Json = {},
data: dict | None = None,
camera: str = "front_door",
) -> ReviewSegment:
"""Inserts a review segment model with a given id."""
if start_time is None:
start_time = datetime.datetime.now().timestamp()
if end_time is None:
end_time = start_time + 20
if data is None:
data = {}
return ReviewSegment.insert(
id=id,
camera="front_door",
camera=camera,
start_time=start_time,
end_time=end_time,
severity=severity,

View File

@ -0,0 +1,169 @@
from unittest.mock import patch
from fastapi import HTTPException, Request
from fastapi.testclient import TestClient
from frigate.api.auth import (
get_allowed_cameras_for_filter,
get_current_user,
)
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import BaseTestHttp
class TestCameraAccessEventReview(BaseTestHttp):
def setUp(self):
super().setUp([Event, ReviewSegment, Recordings])
self.app = super().create_app()
# Mock get_current_user to return valid user for all tests
async def mock_get_current_user():
return {"username": "test_user", "role": "user"}
self.app.dependency_overrides[get_current_user] = mock_get_current_user
def tearDown(self):
self.app.dependency_overrides.clear()
super().tearDown()
def test_event_camera_access(self):
super().insert_mock_event("event1", camera="front_door")
super().insert_mock_event("event2", camera="back_door")
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
"front_door"
]
with TestClient(self.app) as client:
resp = client.get("/events")
assert resp.status_code == 200
ids = [e["id"] for e in resp.json()]
assert "event1" in ids
assert "event2" not in ids
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
"front_door",
"back_door",
]
with TestClient(self.app) as client:
resp = client.get("/events")
assert resp.status_code == 200
ids = [e["id"] for e in resp.json()]
assert "event1" in ids and "event2" in ids
def test_review_camera_access(self):
super().insert_mock_review_segment("rev1", camera="front_door")
super().insert_mock_review_segment("rev2", camera="back_door")
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
"front_door"
]
with TestClient(self.app) as client:
resp = client.get("/review")
assert resp.status_code == 200
ids = [r["id"] for r in resp.json()]
assert "rev1" in ids
assert "rev2" not in ids
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
"front_door",
"back_door",
]
with TestClient(self.app) as client:
resp = client.get("/review")
assert resp.status_code == 200
ids = [r["id"] for r in resp.json()]
assert "rev1" in ids and "rev2" in ids
def test_event_single_access(self):
super().insert_mock_event("event1", camera="front_door")
# Allowed
async def mock_require_allowed(camera: str, request: Request = None):
if camera == "front_door":
return
raise HTTPException(status_code=403, detail="Access denied")
with patch("frigate.api.event.require_camera_access", mock_require_allowed):
with TestClient(self.app) as client:
resp = client.get("/events/event1")
assert resp.status_code == 200
assert resp.json()["id"] == "event1"
# Disallowed
async def mock_require_disallowed(camera: str, request: Request = None):
raise HTTPException(status_code=403, detail="Access denied")
with patch("frigate.api.event.require_camera_access", mock_require_disallowed):
with TestClient(self.app) as client:
resp = client.get("/events/event1")
assert resp.status_code == 403
def test_review_single_access(self):
super().insert_mock_review_segment("rev1", camera="front_door")
# Allowed
async def mock_require_allowed(camera: str, request: Request = None):
if camera == "front_door":
return
raise HTTPException(status_code=403, detail="Access denied")
with patch("frigate.api.review.require_camera_access", mock_require_allowed):
with TestClient(self.app) as client:
resp = client.get("/review/rev1")
assert resp.status_code == 200
assert resp.json()["id"] == "rev1"
# Disallowed
async def mock_require_disallowed(camera: str, request: Request = None):
raise HTTPException(status_code=403, detail="Access denied")
with patch("frigate.api.review.require_camera_access", mock_require_disallowed):
with TestClient(self.app) as client:
resp = client.get("/review/rev1")
assert resp.status_code == 403
def test_event_search_access(self):
super().insert_mock_event("event1", camera="front_door")
super().insert_mock_event("event2", camera="back_door")
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
"front_door"
]
with TestClient(self.app) as client:
resp = client.get("/events", params={"cameras": "all"})
assert resp.status_code == 200
ids = [e["id"] for e in resp.json()]
assert "event1" in ids
assert "event2" not in ids
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
"front_door",
"back_door",
]
with TestClient(self.app) as client:
resp = client.get("/events", params={"cameras": "all"})
assert resp.status_code == 200
ids = [e["id"] for e in resp.json()]
assert "event1" in ids and "event2" in ids
def test_event_summary_access(self):
super().insert_mock_event("event1", camera="front_door")
super().insert_mock_event("event2", camera="back_door")
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
"front_door"
]
with TestClient(self.app) as client:
resp = client.get("/events/summary")
assert resp.status_code == 200
summary_list = resp.json()
assert len(summary_list) == 1
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
"front_door",
"back_door",
]
with TestClient(self.app) as client:
resp = client.get("/events/summary")
summary_list = resp.json()
assert len(summary_list) == 2

View File

@ -1,16 +1,34 @@
from datetime import datetime
from typing import Any
from unittest.mock import Mock
from fastapi.testclient import TestClient
from playhouse.shortcuts import model_to_dict
from frigate.models import Event, Recordings, ReviewSegment
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.comms.event_metadata_updater import EventMetadataPublisher
from frigate.models import Event, Recordings, ReviewSegment, Timeline
from frigate.test.http_api.base_http_test import BaseTestHttp
class TestHttpApp(BaseTestHttp):
def setUp(self):
super().setUp([Event, Recordings, ReviewSegment])
super().setUp([Event, Recordings, ReviewSegment, Timeline])
self.app = super().create_app()
# Mock auth to bypass camera access for tests
async def mock_get_current_user(request: Any):
return {"username": "test_user", "role": "admin"}
self.app.dependency_overrides[get_current_user] = mock_get_current_user
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
"front_door"
]
def tearDown(self):
self.app.dependency_overrides.clear()
super().tearDown()
####################################################################################################################
################################### GET /events Endpoint #########################################################
####################################################################################################################
@ -135,3 +153,143 @@ class TestHttpApp(BaseTestHttp):
assert len(events) == 2
assert events[0]["id"] == id
assert events[1]["id"] == id2
def test_get_good_event(self):
id = "123456.random"
with TestClient(self.app) as client:
super().insert_mock_event(id)
event = client.get(f"/events/{id}").json()
assert event
assert event["id"] == id
assert event["id"] == model_to_dict(Event.get(Event.id == id))["id"]
def test_get_bad_event(self):
id = "123456.random"
bad_id = "654321.other"
with TestClient(self.app) as client:
super().insert_mock_event(id)
event_response = client.get(f"/events/{bad_id}")
assert event_response.status_code == 404
assert event_response.json() == "Event not found"
def test_delete_event(self):
id = "123456.random"
with TestClient(self.app) as client:
super().insert_mock_event(id)
event = client.get(f"/events/{id}").json()
assert event
assert event["id"] == id
response = client.delete(f"/events/{id}", headers={"remote-role": "admin"})
assert response.status_code == 200
event_after_delete = client.get(f"/events/{id}")
assert event_after_delete.status_code == 404
def test_event_retention(self):
id = "123456.random"
with TestClient(self.app) as client:
super().insert_mock_event(id)
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", headers={"remote-role": "admin"})
event = client.get(f"/events/{id}").json()
assert event
assert event["id"] == id
assert event["retain_indefinitely"] is False
def test_event_time_filtering(self):
morning_id = "123456.random"
evening_id = "654321.random"
morning = 1656590400 # 06/30/2022 6 am (GMT)
evening = 1656633600 # 06/30/2022 6 pm (GMT)
with TestClient(self.app) as client:
super().insert_mock_event(morning_id, morning)
super().insert_mock_event(evening_id, evening)
# both events come back
events = client.get("/events").json()
print("events!!!", events)
assert events
assert len(events) == 2
# morning event is excluded
events = client.get(
"/events",
params={"time_range": "07:00,24:00"},
).json()
assert events
assert len(events) == 1
# evening event is excluded
events = client.get(
"/events",
params={"time_range": "00:00,18:00"},
).json()
assert events
assert len(events) == 1
def test_set_delete_sub_label(self):
mock_event_updater = Mock(spec=EventMetadataPublisher)
app = super().create_app(event_metadata_publisher=mock_event_updater)
id = "123456.random"
sub_label = "sub"
def update_event(payload: Any, topic: str):
event = Event.get(id=id)
event.sub_label = payload[1]
event.save()
mock_event_updater.publish.side_effect = update_event
with TestClient(app) as client:
super().insert_mock_event(id)
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()
assert event
assert event["id"] == id
assert event["sub_label"] == sub_label
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()
assert event
assert event["id"] == id
assert event["sub_label"] == None
def test_sub_label_list(self):
mock_event_updater = Mock(spec=EventMetadataPublisher)
app = super().create_app(event_metadata_publisher=mock_event_updater)
app.event_metadata_publisher = mock_event_updater
id = "123456.random"
sub_label = "sub"
def update_event(payload: Any, _: str):
event = Event.get(id=id)
event.sub_label = payload[1]
event.save()
mock_event_updater.publish.side_effect = update_event
with TestClient(app) as client:
super().insert_mock_event(id)
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
assert sub_labels == [sub_label]

View File

@ -3,7 +3,7 @@ from datetime import datetime, timedelta
from fastapi.testclient import TestClient
from peewee import DoesNotExist
from frigate.api.auth import get_current_user
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.models import Event, Recordings, ReviewSegment, UserReviewStatus
from frigate.review.types import SeverityEnum
from frigate.test.http_api.base_http_test import BaseTestHttp
@ -21,6 +21,10 @@ class TestHttpReview(BaseTestHttp):
self.app.dependency_overrides[get_current_user] = mock_get_current_user
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
"front_door"
]
def tearDown(self):
self.app.dependency_overrides.clear()
super().tearDown()

View File

@ -1,329 +0,0 @@
import datetime
import logging
import os
import unittest
from typing import Any
from unittest.mock import Mock
from fastapi.testclient import TestClient
from peewee_migrate import Router
from playhouse.shortcuts import model_to_dict
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase
from frigate.api.fastapi_app import create_fastapi_app
from frigate.comms.event_metadata_updater import EventMetadataPublisher
from frigate.config import FrigateConfig
from frigate.const import BASE_DIR, CACHE_DIR
from frigate.models import Event, Recordings, Timeline
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
class TestHttp(unittest.TestCase):
def setUp(self):
# setup clean database for each test run
migrate_db = SqliteExtDatabase("test.db")
del logging.getLogger("peewee_migrate").handlers[:]
router = Router(migrate_db)
router.run()
migrate_db.close()
self.db = SqliteQueueDatabase(TEST_DB)
models = [Event, Recordings, Timeline]
self.db.bind(models)
self.minimal_config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
self.test_stats = {
"camera_fps": 5.0,
"process_fps": 5.0,
"skipped_fps": 0.0,
"detection_fps": 13.7,
"detectors": {
"cpu1": {
"detection_start": 0.0,
"inference_speed": 91.43,
"pid": 42,
},
"cpu2": {
"detection_start": 0.0,
"inference_speed": 84.99,
"pid": 44,
},
},
"front_door": {
"camera_fps": 0.0,
"capture_pid": 53,
"detection_fps": 0.0,
"pid": 52,
"process_fps": 0.0,
"skipped_fps": 0.0,
},
"service": {
"storage": {
"/dev/shm": {
"free": 50.5,
"mount_type": "tmpfs",
"total": 67.1,
"used": 16.6,
},
os.path.join(BASE_DIR, "clips"): {
"free": 42429.9,
"mount_type": "ext4",
"total": 244529.7,
"used": 189607.0,
},
os.path.join(BASE_DIR, "recordings"): {
"free": 0.2,
"mount_type": "ext4",
"total": 8.0,
"used": 7.8,
},
CACHE_DIR: {
"free": 976.8,
"mount_type": "tmpfs",
"total": 1000.0,
"used": 23.2,
},
},
"uptime": 101113,
"version": "0.10.1",
"latest_version": "0.11",
},
}
def tearDown(self):
if not self.db.is_closed():
self.db.close()
try:
for file in TEST_DB_CLEANUPS:
os.remove(file)
except OSError:
pass
def __init_app(self, updater: Any | None = None) -> Any:
return create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
updater,
None,
)
def test_get_good_event(self):
app = self.__init_app()
id = "123456.random"
with TestClient(app) as client:
_insert_mock_event(id)
event = client.get(f"/events/{id}").json()
assert event
assert event["id"] == id
assert event["id"] == model_to_dict(Event.get(Event.id == id))["id"]
def test_get_bad_event(self):
app = self.__init_app()
id = "123456.random"
bad_id = "654321.other"
with TestClient(app) as client:
_insert_mock_event(id)
event_response = client.get(f"/events/{bad_id}")
assert event_response.status_code == 404
assert event_response.json() == "Event not found"
def test_delete_event(self):
app = self.__init_app()
id = "123456.random"
with TestClient(app) as client:
_insert_mock_event(id)
event = client.get(f"/events/{id}").json()
assert event
assert event["id"] == id
client.delete(f"/events/{id}", headers={"remote-role": "admin"})
event = client.get(f"/events/{id}").json()
assert event == "Event not found"
def test_event_retention(self):
app = self.__init_app()
id = "123456.random"
with TestClient(app) as client:
_insert_mock_event(id)
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", headers={"remote-role": "admin"})
event = client.get(f"/events/{id}").json()
assert event
assert event["id"] == id
assert event["retain_indefinitely"] is False
def test_event_time_filtering(self):
app = self.__init_app()
morning_id = "123456.random"
evening_id = "654321.random"
morning = 1656590400 # 06/30/2022 6 am (GMT)
evening = 1656633600 # 06/30/2022 6 pm (GMT)
with TestClient(app) as client:
_insert_mock_event(morning_id, morning)
_insert_mock_event(evening_id, evening)
# both events come back
events = client.get("/events").json()
assert events
assert len(events) == 2
# morning event is excluded
events = client.get(
"/events",
params={"time_range": "07:00,24:00"},
).json()
assert events
# assert len(events) == 1
# evening event is excluded
events = client.get(
"/events",
params={"time_range": "00:00,18:00"},
).json()
assert events
assert len(events) == 1
def test_set_delete_sub_label(self):
mock_event_updater = Mock(spec=EventMetadataPublisher)
app = app = self.__init_app(updater=mock_event_updater)
id = "123456.random"
sub_label = "sub"
def update_event(payload: Any, topic: str):
event = Event.get(id=id)
event.sub_label = payload[1]
event.save()
mock_event_updater.publish.side_effect = update_event
with TestClient(app) as client:
_insert_mock_event(id)
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()
assert event
assert event["id"] == id
assert event["sub_label"] == sub_label
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()
assert event
assert event["id"] == id
assert event["sub_label"] == None
def test_sub_label_list(self):
mock_event_updater = Mock(spec=EventMetadataPublisher)
app = self.__init_app(updater=mock_event_updater)
id = "123456.random"
sub_label = "sub"
def update_event(payload: Any, _: str):
event = Event.get(id=id)
event.sub_label = payload[1]
event.save()
mock_event_updater.publish.side_effect = update_event
with TestClient(app) as client:
_insert_mock_event(id)
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
assert sub_labels == [sub_label]
def test_config(self):
app = self.__init_app()
with TestClient(app) as client:
config = client.get("/config").json()
assert config
assert config["cameras"]["front_door"]
def test_recordings(self):
app = self.__init_app()
id = "123456.random"
with TestClient(app) as client:
_insert_mock_recording(id)
response = client.get("/front_door/recordings")
assert response.status_code == 200
recording = response.json()
assert recording
assert recording[0]["id"] == id
def _insert_mock_event(
id: str,
start_time: datetime.datetime = datetime.datetime.now().timestamp(),
) -> Event:
"""Inserts a basic event model with a given id."""
return Event.insert(
id=id,
label="Mock",
camera="front_door",
start_time=start_time,
end_time=start_time + 20,
top_score=100,
false_positive=False,
zones=list(),
thumbnail="",
region=[],
box=[],
area=0,
has_clip=True,
has_snapshot=True,
).execute()
def _insert_mock_recording(id: str) -> Event:
"""Inserts a basic recording model with a given id."""
return Recordings.insert(
id=id,
camera="front_door",
path=f"/recordings/{id}",
start_time=datetime.datetime.now().timestamp() - 60,
end_time=datetime.datetime.now().timestamp() - 50,
duration=10,
motion=True,
objects=True,
).execute()

View File

@ -19,61 +19,60 @@ class TestProxyRoleResolution(unittest.TestCase):
},
),
)
self.config_roles = list(["admin", "viewer"])
def test_role_map_single_group_match(self):
headers = {"x-remote-role": "group_admin"}
role = resolve_role(headers, self.proxy_config)
role = resolve_role(headers, self.proxy_config, self.config_roles)
self.assertEqual(role, "admin")
def test_role_map_multiple_groups(self):
headers = {"x-remote-role": "group_viewer|group_admin"}
role = resolve_role(headers, self.proxy_config)
# admin should win since VALID_ROLES priority puts it before viewer
headers = {"x-remote-role": "group_admin|group_viewer"}
role = resolve_role(headers, self.proxy_config, self.config_roles)
self.assertEqual(role, "admin")
def test_direct_role_header_with_separator(self):
config = self.proxy_config
config.header_map.role_map = None # disable role_map
headers = {"x-remote-role": "viewer|admin"}
role = resolve_role(headers, config)
# admin should be chosen since it appears in VALID_ROLES
headers = {"x-remote-role": "admin|viewer"}
role = resolve_role(headers, config, self.config_roles)
self.assertEqual(role, "admin")
def test_invalid_role_header(self):
config = self.proxy_config
config.header_map.role_map = None
headers = {"x-remote-role": "notarole"}
role = resolve_role(headers, config)
role = resolve_role(headers, config, self.config_roles)
self.assertEqual(role, config.default_role)
def test_missing_role_header(self):
headers = {}
role = resolve_role(headers, self.proxy_config)
role = resolve_role(headers, self.proxy_config, self.config_roles)
self.assertEqual(role, self.proxy_config.default_role)
def test_empty_role_header(self):
headers = {"x-remote-role": ""}
role = resolve_role(headers, self.proxy_config)
role = resolve_role(headers, self.proxy_config, self.config_roles)
self.assertEqual(role, self.proxy_config.default_role)
def test_whitespace_groups(self):
headers = {"x-remote-role": " | group_admin | "}
role = resolve_role(headers, self.proxy_config)
role = resolve_role(headers, self.proxy_config, self.config_roles)
self.assertEqual(role, "admin")
def test_mixed_valid_and_invalid_groups(self):
headers = {"x-remote-role": "bogus|group_viewer"}
role = resolve_role(headers, self.proxy_config)
role = resolve_role(headers, self.proxy_config, self.config_roles)
self.assertEqual(role, "viewer")
def test_case_insensitive_role_direct(self):
config = self.proxy_config
config.header_map.role_map = None
headers = {"x-remote-role": "AdMiN"}
role = resolve_role(headers, config)
role = resolve_role(headers, config, self.config_roles)
self.assertEqual(role, "admin")
def test_role_map_no_match_falls_back(self):
headers = {"x-remote-role": "group_unknown"}
role = resolve_role(headers, self.proxy_config)
role = resolve_role(headers, self.proxy_config, self.config_roles)
self.assertEqual(role, self.proxy_config.default_role)

View File

@ -556,7 +556,68 @@
"admin": "Admin",
"adminDesc": "Full access to all features.",
"viewer": "Viewer",
"viewerDesc": "Limited to Live dashboards, Review, Explore, and Exports only."
"viewerDesc": "Limited to Live dashboards, Review, Explore, and Exports only.",
"customDesc": "Custom role with specific camera access."
}
}
}
},
"roles": {
"management": {
"title": "Viewer Role Management",
"desc": "Manage custom viewer roles and their camera access permissions for this Frigate instance."
},
"addRole": "Add Role",
"table": {
"role": "Role",
"cameras": "Cameras",
"actions": "Actions",
"noRoles": "No custom roles found.",
"editCameras": "Edit Cameras",
"deleteRole": "Delete Role"
},
"toast": {
"success": {
"createRole": "Role {{role}} created successfully",
"updateCameras": "Cameras updated for role {{role}}",
"deleteRole": "Role {{role}} deleted successfully",
"userRolesUpdated": "{{count}} user(s) assigned to this role have been updated to 'viewer', which has access to all cameras."
},
"error": {
"createRoleFailed": "Failed to create role: {{errorMessage}}",
"updateCamerasFailed": "Failed to update cameras: {{errorMessage}}",
"deleteRoleFailed": "Failed to delete role: {{errorMessage}}",
"userUpdateFailed": "Failed to update user roles: {{errorMessage}}"
}
},
"dialog": {
"createRole": {
"title": "Create New Role",
"desc": "Add a new role and specify camera access permissions."
},
"editCameras": {
"title": "Edit Role Cameras",
"desc": "Update camera access for the role <strong>{{role}}</strong>."
},
"deleteRole": {
"title": "Delete Role",
"desc": "This action cannot be undone. This will permanently delete the role and assign any users with this role to the 'viewer' role, which will give viewer access to all cameras.",
"warn": "Are you sure you want to delete <strong>{{role}}</strong>?",
"deleting": "Deleting..."
},
"form": {
"role": {
"title": "Role Name",
"placeholder": "Enter role name",
"desc": "Only letters, numbers, periods and underscores allowed.",
"roleIsRequired": "Role name is required",
"roleOnlyInclude": "Role name may only include letters, numbers, . or _",
"roleExists": "A role with this name already exists."
},
"cameras": {
"title": "Cameras",
"desc": "Select cameras this role has access to. At least one camera is required.",
"required": "At least one camera must be selected."
}
}
}

View File

@ -47,6 +47,9 @@ function App() {
}
function DefaultAppView() {
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
return (
<div className="size-full overflow-hidden">
{isDesktop && <Sidebar />}
@ -64,7 +67,15 @@ function DefaultAppView() {
<Suspense>
<Routes>
<Route
element={<ProtectedRoute requiredRoles={["viewer", "admin"]} />}
element={
<ProtectedRoute
requiredRoles={
config?.auth.roles
? Object.keys(config.auth.roles)
: ["admin", "viewer"]
}
/>
}
>
<Route index element={<Live />} />
<Route path="/review" element={<Events />} />

View File

@ -6,7 +6,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
export default function ProtectedRoute({
requiredRoles,
}: {
requiredRoles: ("admin" | "viewer")[];
requiredRoles: string[];
}) {
const { auth } = useContext(AuthContext);

View File

@ -77,6 +77,8 @@ import { DialogTrigger } from "@radix-ui/react-dialog";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { Trans, useTranslation } from "react-i18next";
import { CameraNameLabel } from "../camera/CameraNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
type CameraGroupSelectorProps = {
className?: string;
@ -650,6 +652,9 @@ export function CameraGroupEdit({
allGroupsStreamingSettings[editingGroup?.[0] ?? ""],
);
const allowedCameras = useAllowedCameras();
const isCustomRole = useIsCustomRole();
const [openCamera, setOpenCamera] = useState<string | null>();
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
@ -837,8 +842,13 @@ export function CameraGroupEdit({
<FormDescription>{t("group.cameras.desc")}</FormDescription>
<FormMessage />
{[
...(birdseyeConfig?.enabled ? ["birdseye"] : []),
...Object.keys(config?.cameras ?? {}).sort(
...(birdseyeConfig?.enabled &&
(!isCustomRole || "birdseye" in allowedCameras)
? ["birdseye"]
: []),
...Object.keys(config?.cameras ?? {})
.filter((camera) => allowedCameras.includes(camera))
.sort(
(a, b) =>
(config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),

View File

@ -25,6 +25,7 @@ import { CamerasFilterButton } from "./CamerasFilterButton";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
const REVIEW_FILTERS = [
"cameras",
@ -72,6 +73,7 @@ export default function ReviewFilterGroup({
setMotionOnly,
}: ReviewFilterGroupProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const allowedCameras = useAllowedCameras();
const allLabels = useMemo<string[]>(() => {
if (filterList?.labels) {
@ -83,7 +85,9 @@ export default function ReviewFilterGroup({
}
const labels = new Set<string>();
const cameras = filter?.cameras || Object.keys(config.cameras);
const cameras = (filter?.cameras || allowedCameras).filter((camera) =>
allowedCameras.includes(camera),
);
cameras.forEach((camera) => {
if (camera == "birdseye") {
@ -106,7 +110,7 @@ export default function ReviewFilterGroup({
});
return [...labels].sort();
}, [config, filterList, filter]);
}, [config, filterList, filter, allowedCameras]);
const allZones = useMemo<string[]>(() => {
if (filterList?.zones) {
@ -118,7 +122,9 @@ export default function ReviewFilterGroup({
}
const zones = new Set<string>();
const cameras = filter?.cameras || Object.keys(config.cameras);
const cameras = (filter?.cameras || allowedCameras).filter((camera) =>
allowedCameras.includes(camera),
);
cameras.forEach((camera) => {
if (camera == "birdseye") {
@ -134,11 +140,11 @@ export default function ReviewFilterGroup({
});
return [...zones].sort();
}, [config, filterList, filter]);
}, [config, filterList, filter, allowedCameras]);
const filterValues = useMemo(
() => ({
cameras: Object.keys(config?.cameras ?? {}).sort(
cameras: allowedCameras.sort(
(a, b) =>
(config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),
@ -146,7 +152,7 @@ export default function ReviewFilterGroup({
labels: Object.values(allLabels || {}),
zones: Object.values(allZones || {}),
}),
[config, allLabels, allZones],
[config, allLabels, allZones, allowedCameras],
);
const groups = useMemo(() => {

View File

@ -24,9 +24,9 @@ import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog";
import { CalendarRangeFilterButton } from "./CalendarFilterButton";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
type SearchFilterGroupProps = {
className: string;
@ -46,6 +46,7 @@ export default function SearchFilterGroup({
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const allowedCameras = useAllowedCameras();
const allLabels = useMemo<string[]>(() => {
if (filterList?.labels) {
@ -57,7 +58,9 @@ export default function SearchFilterGroup({
}
const labels = new Set<string>();
const cameras = filter?.cameras || Object.keys(config.cameras);
const cameras = (filter?.cameras || allowedCameras).filter((camera) =>
allowedCameras.includes(camera),
);
cameras.forEach((camera) => {
if (camera == "birdseye") {
@ -87,7 +90,7 @@ export default function SearchFilterGroup({
});
return [...labels].sort();
}, [config, filterList, filter]);
}, [config, filterList, filter, allowedCameras]);
const allZones = useMemo<string[]>(() => {
if (filterList?.zones) {
@ -99,7 +102,9 @@ export default function SearchFilterGroup({
}
const zones = new Set<string>();
const cameras = filter?.cameras || Object.keys(config.cameras);
const cameras = (filter?.cameras || allowedCameras).filter((camera) =>
allowedCameras.includes(camera),
);
cameras.forEach((camera) => {
if (camera == "birdseye") {
@ -118,16 +123,16 @@ export default function SearchFilterGroup({
});
return [...zones].sort();
}, [config, filterList, filter]);
}, [config, filterList, filter, allowedCameras]);
const filterValues = useMemo(
() => ({
cameras: Object.keys(config?.cameras || {}),
cameras: allowedCameras,
labels: Object.values(allLabels || {}),
zones: Object.values(allZones || {}),
search_type: ["thumbnail", "description"] as SearchSource[],
}),
[config, allLabels, allZones],
[allLabels, allZones, allowedCameras],
);
const availableSortTypes = useMemo(() => {

View File

@ -0,0 +1,228 @@
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Switch } from "@/components/ui/switch";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useTranslation } from "react-i18next";
import { FrigateConfig } from "@/types/frigateConfig";
import { CameraNameLabel } from "../camera/CameraNameLabel";
type CreateRoleOverlayProps = {
show: boolean;
config: FrigateConfig;
onCreate: (role: string, cameras: string[]) => void;
onCancel: () => void;
};
export default function CreateRoleDialog({
show,
config,
onCreate,
onCancel,
}: CreateRoleOverlayProps) {
const { t } = useTranslation(["views/settings"]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const cameras = Object.keys(config.cameras || {});
const existingRoles = Object.keys(config.auth?.roles || {});
const formSchema = z.object({
role: z
.string()
.min(1, t("roles.dialog.form.role.roleIsRequired"))
.regex(/^[A-Za-z0-9._]+$/, {
message: t("roles.dialog.form.role.roleOnlyInclude"),
})
.refine((role) => !existingRoles.includes(role), {
message: t("roles.dialog.form.role.roleExists"),
}),
cameras: z
.array(z.string())
.min(1, t("roles.dialog.form.cameras.required")),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
role: "",
cameras: [],
},
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
setIsLoading(true);
try {
await onCreate(values.role, values.cameras);
form.reset();
} catch (error) {
// Error handled in parent
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!show) {
form.reset({
role: "",
cameras: [],
});
}
}, [show, form]);
const handleCancel = () => {
form.reset({
role: "",
cameras: [],
});
onCancel();
};
return (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t("roles.dialog.createRole.title")}</DialogTitle>
<DialogDescription>
{t("roles.dialog.createRole.desc")}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 pt-4"
>
<FormField
name="role"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
{t("roles.dialog.form.role.title")}
</FormLabel>
<FormControl>
<Input
placeholder={t("roles.dialog.form.role.placeholder")}
className="h-10"
{...field}
/>
</FormControl>
<FormDescription className="text-xs text-muted-foreground">
{t("roles.dialog.form.role.desc")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel>{t("roles.dialog.form.cameras.title")}</FormLabel>
<FormDescription className="text-xs text-muted-foreground">
{t("roles.dialog.form.cameras.desc")}
</FormDescription>
<div className="scrollbar-container max-h-[40dvh] space-y-2 overflow-y-auto">
{cameras.map((camera) => (
<FormField
key={camera}
control={form.control}
name="cameras"
render={({ field }) => {
return (
<FormItem
key={camera}
className="flex flex-row items-center justify-between space-x-3 space-y-0"
>
<div className="space-y-0.5">
<FormLabel className="font-normal">
<CameraNameLabel
className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
htmlFor={camera.replaceAll("_", " ")}
camera={camera}
/>
</FormLabel>
</div>
<FormControl>
<Switch
checked={field.value?.includes(camera)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value as string[]),
camera,
])
: field.onChange(
(field.value as string[])?.filter(
(value: string) => value !== camera,
) || [],
);
}}
/>
</FormControl>
</FormItem>
);
}}
/>
))}
</div>
<FormMessage />
</div>
<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={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -190,7 +190,7 @@ export default function CreateTriggerDialog({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 py-4"
className="space-y-5 pt-4"
>
<FormField
control={form.control}

View File

@ -36,7 +36,7 @@ import { useTranslation } from "react-i18next";
type CreateUserOverlayProps = {
show: boolean;
onCreate: (user: string, password: string, role: "admin" | "viewer") => void;
onCreate: (user: string, password: string, role: string) => void;
onCancel: () => void;
};
@ -123,7 +123,7 @@ export default function CreateUserDialog({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 py-4"
className="space-y-5 pt-4"
>
<FormField
name="user"

View File

@ -0,0 +1,109 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Trans } from "react-i18next";
import { useTranslation } from "react-i18next";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useState } from "react";
type DeleteRoleDialogProps = {
show: boolean;
role: string;
onCancel: () => void;
onDelete: () => void;
};
export default function DeleteRoleDialog({
show,
role,
onCancel,
onDelete,
}: DeleteRoleDialogProps) {
const { t } = useTranslation("views/settings");
const [isLoading, setIsLoading] = useState<boolean>(false);
const handleDelete = async () => {
setIsLoading(true);
try {
await onDelete();
} catch (error) {
// Error handled in parent
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t("roles.dialog.deleteRole.title")}</DialogTitle>
<DialogDescription>
<Trans
i18nKey="roles.dialog.deleteRole.desc"
ns="views/settings"
values={{ role }}
components={{
strong: <span className="font-medium" />,
}}
/>
</DialogDescription>
</DialogHeader>
<div className="py-3">
<div className="text-sm text-muted-foreground">
<p>
<Trans
ns={"views/settings"}
values={{ role }}
components={{ strong: <span className="font-medium" /> }}
>
roles.dialog.deleteRole.warn
</Trans>
</p>
</div>
</div>
<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={t("button.cancel", { ns: "common" })}
variant="outline"
disabled={isLoading}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
className="flex flex-1"
aria-label={t("button.delete", { ns: "common" })}
variant="destructive"
disabled={isLoading}
onClick={handleDelete}
type="button"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("roles.dialog.deleteRole.deleting")}</span>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,195 @@
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Switch } from "@/components/ui/switch";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Trans, useTranslation } from "react-i18next";
import { FrigateConfig } from "@/types/frigateConfig";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type EditRoleCamerasOverlayProps = {
show: boolean;
config: FrigateConfig;
role: string;
currentCameras: string[];
onSave: (cameras: string[]) => void;
onCancel: () => void;
};
export default function EditRoleCamerasDialog({
show,
config,
role,
currentCameras,
onSave,
onCancel,
}: EditRoleCamerasOverlayProps) {
const { t } = useTranslation(["views/settings"]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const cameras = Object.keys(config.cameras || {});
const formSchema = z.object({
cameras: z
.array(z.string())
.min(1, t("roles.dialog.form.cameras.required")),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
cameras: currentCameras,
},
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
setIsLoading(true);
try {
await onSave(values.cameras);
form.reset();
} catch (error) {
// Error handled in parent
} finally {
setIsLoading(false);
}
};
const handleCancel = () => {
form.reset({
cameras: currentCameras,
});
onCancel();
};
return (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{t("roles.dialog.editCameras.title", { role })}
</DialogTitle>
<DialogDescription>
<Trans
ns={"views/settings"}
values={{ role }}
components={{ strong: <span className="font-medium" /> }}
>
roles.dialog.editCameras.desc
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 pt-4"
>
<div className="space-y-2">
<FormLabel>{t("roles.dialog.form.cameras.title")}</FormLabel>
<FormDescription className="text-xs text-muted-foreground">
{t("roles.dialog.form.cameras.desc")}
</FormDescription>
<div className="scrollbar-container max-h-[40dvh] space-y-2 overflow-y-auto">
{cameras.map((camera) => (
<FormField
key={camera}
control={form.control}
name="cameras"
render={({ field }) => {
return (
<FormItem
key={camera}
className="flex flex-row items-center justify-between space-x-3 space-y-0"
>
<div className="space-y-0.5">
<FormLabel className="font-normal">
<CameraNameLabel
className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
htmlFor={camera.replaceAll("_", " ")}
camera={camera}
/>
</FormLabel>
</div>
<FormControl>
<Switch
checked={field.value?.includes(camera)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value as string[]),
camera,
])
: field.onChange(
(field.value as string[])?.filter(
(value: string) => value !== camera,
) || [],
);
}}
/>
</FormControl>
</FormItem>
);
}}
/>
))}
</div>
<FormMessage />
</div>
<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={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -1,5 +1,5 @@
import { Trans, useTranslation } from "react-i18next";
import { Button } from "../ui/button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@ -7,22 +7,23 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
} from "@/components/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;
currentRole: string;
availableRoles: string[];
onSave: (role: string) => void;
onCancel: () => void;
};
@ -30,13 +31,12 @@ export default function RoleChangeDialog({
show,
username,
currentRole,
availableRoles,
onSave,
onCancel,
}: RoleChangeDialogProps) {
const { t } = useTranslation(["views/settings"]);
const [selectedRole, setSelectedRole] = useState<"admin" | "viewer">(
currentRole,
);
const [selectedRole, setSelectedRole] = useState<string>(currentRole);
return (
<Dialog open={show} onOpenChange={onCancel}>
@ -73,31 +73,46 @@ export default function RoleChangeDialog({
</span>
: {t("users.dialog.changeRole.roleInfo.viewerDesc")}
</li>
{availableRoles
.filter((role) => role !== "admin" && role !== "viewer")
.map((role) => (
<li key={role}>
<span className="font-medium">{role}</span>:{" "}
{t("users.dialog.changeRole.roleInfo.customDesc")}
</li>
))}
</ul>
</div>
<Select
value={selectedRole}
onValueChange={(value) =>
setSelectedRole(value as "admin" | "viewer")
}
>
<Select value={selectedRole} onValueChange={setSelectedRole}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("users.dialog.changeRole.select")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin" className="flex items-center gap-2">
{availableRoles.map((role) => (
<SelectItem
key={role}
value={role}
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
{role === "admin" ? (
<LuShield className="size-4 text-primary" />
<span>{t("role.admin", { ns: "common" })}</span>
</div>
</SelectItem>
<SelectItem value="viewer" className="flex items-center gap-2">
<div className="flex items-center gap-2">
) : role === "viewer" ? (
<LuUser className="size-4 text-primary" />
<span>{t("role.viewer", { ns: "common" })}</span>
) : (
<LuUser className="size-4 text-muted-foreground" />
)}
<span>
{role === "admin"
? t("role.admin", { ns: "common" })
: role === "viewer"
? t("role.viewer", { ns: "common" })
: role}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
@ -108,6 +123,7 @@ export default function RoleChangeDialog({
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel}
type="button"
>
@ -118,6 +134,7 @@ export default function RoleChangeDialog({
aria-label={t("button.save", { ns: "common" })}
className="flex flex-1"
onClick={() => onSave(selectedRole)}
type="button"
disabled={selectedRole === currentRole}
>
{t("button.save", { ns: "common" })}

View File

@ -114,7 +114,7 @@ export default function SetPasswordDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label htmlFor="password">
{t("users.dialog.form.newPassword.title")}

View File

@ -3,7 +3,8 @@ import { createContext, useEffect, useState } from "react";
import useSWR from "swr";
interface AuthState {
user: { username: string; role: "admin" | "viewer" | null } | null;
user: { username: string; role: string | null } | null;
allowedCameras: string[];
isLoading: boolean;
isAuthenticated: boolean; // true if auth is required
}
@ -15,7 +16,12 @@ interface AuthContextType {
}
export const AuthContext = createContext<AuthContextType>({
auth: { user: null, isLoading: true, isAuthenticated: false },
auth: {
user: null,
allowedCameras: [],
isLoading: true,
isAuthenticated: false,
},
login: () => {},
logout: () => {},
});
@ -23,6 +29,7 @@ export const AuthContext = createContext<AuthContextType>({
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [auth, setAuth] = useState<AuthState>({
user: null,
allowedCameras: [],
isLoading: true,
isAuthenticated: false,
});
@ -38,7 +45,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
// auth required but not logged in
setAuth({ user: null, isLoading: false, isAuthenticated: true });
setAuth({
user: null,
allowedCameras: [],
isLoading: false,
isAuthenticated: true,
});
}
return;
}
@ -49,20 +61,44 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
username: profile.username,
role: profile.role || "viewer",
};
setAuth({ user: newUser, isLoading: false, isAuthenticated: true });
const allowedCameras = Array.isArray(profile.allowed_cameras)
? profile.allowed_cameras
: [];
setAuth({
user: newUser,
allowedCameras,
isLoading: false,
isAuthenticated: true,
});
} else {
// Unauthenticated mode (anonymous)
setAuth({ user: null, isLoading: false, isAuthenticated: false });
setAuth({
user: null,
allowedCameras: [],
isLoading: false,
isAuthenticated: false,
});
}
}
}, [profile, error]);
const login = (user: AuthState["user"]) => {
setAuth({ user, isLoading: false, isAuthenticated: true });
setAuth((current) => ({
...current,
user,
isLoading: false,
isAuthenticated: true,
}));
};
const logout = () => {
setAuth({ user: null, isLoading: false, isAuthenticated: true });
setAuth({
user: null,
allowedCameras: [],
isLoading: false,
isAuthenticated: true,
});
axios.get("/logout", { withCredentials: true });
};

View File

@ -0,0 +1,22 @@
import { useContext } from "react";
import { AuthContext } from "@/context/auth-context";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
export function useAllowedCameras() {
const { auth } = useContext(AuthContext);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
if (
auth.user?.role === "viewer" ||
auth.user?.role === "admin" ||
!auth.isAuthenticated // anonymous port 5000
) {
// return all cameras
return config?.cameras ? Object.keys(config.cameras) : [];
}
return auth.allowedCameras || [];
}

View File

@ -0,0 +1,11 @@
import { useContext } from "react";
import { AuthContext } from "@/context/auth-context";
export function useIsCustomRole() {
const { auth } = useContext(AuthContext);
return !(
auth.user?.role === "admin" ||
auth.user?.role == "viewer" ||
!auth.isAuthenticated
);
}

View File

@ -9,6 +9,7 @@ import { LuConstruction } from "react-icons/lu";
import { MdCategory, MdVideoLibrary } from "react-icons/md";
import { TbFaceId } from "react-icons/tb";
import useSWR from "swr";
import { useIsAdmin } from "./use-is-admin";
export const ID_LIVE = 1;
export const ID_REVIEW = 2;
@ -24,6 +25,7 @@ export default function useNavigation(
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const isAdmin = useIsAdmin();
return useMemo(
() =>
@ -70,7 +72,7 @@ export default function useNavigation(
icon: TbFaceId,
title: "menu.faceLibrary",
url: "/faces",
enabled: isDesktop && config?.face_recognition.enabled,
enabled: isDesktop && config?.face_recognition.enabled && isAdmin,
},
{
id: ID_CLASSIFICATION,
@ -78,9 +80,9 @@ export default function useNavigation(
icon: MdCategory,
title: "menu.classification",
url: "/classification",
enabled: isDesktop,
enabled: isDesktop && isAdmin,
},
] as NavData[],
[config?.face_recognition?.enabled, variant],
[config?.face_recognition?.enabled, variant, isAdmin],
);
}

View File

@ -13,10 +13,13 @@ import { useTranslation } from "react-i18next";
import { useEffect, useMemo, useRef } from "react";
import useSWR from "swr";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
function Live() {
const { t } = useTranslation(["views/live"]);
const { data: config } = useSWR<FrigateConfig>("config");
const isCustomRole = useIsCustomRole();
// selection
@ -81,19 +84,22 @@ function Live() {
// settings
const allowedCameras = useAllowedCameras();
const includesBirdseye = useMemo(() => {
if (
config &&
Object.keys(config.camera_groups).length &&
cameraGroup &&
config.camera_groups[cameraGroup] &&
cameraGroup != "default"
cameraGroup != "default" &&
(!isCustomRole || "birdseye" in allowedCameras)
) {
return config.camera_groups[cameraGroup].cameras.includes("birdseye");
} else {
return false;
}
}, [config, cameraGroup]);
}, [config, cameraGroup, allowedCameras, isCustomRole]);
const cameras = useMemo(() => {
if (!config) {
@ -111,13 +117,15 @@ function Live() {
.filter(
(conf) => conf.enabled_in_config && group.cameras.includes(conf.name),
)
.filter((cam) => allowedCameras.includes(cam.name))
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled_in_config)
.filter((cam) => allowedCameras.includes(cam.name))
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config, cameraGroup]);
}, [config, cameraGroup, allowedCameras]);
const selectedCamera = useMemo(
() => cameras.find((cam) => cam.name == selectedCameraName),

View File

@ -33,7 +33,8 @@ import CameraSettingsView from "@/views/settings/CameraSettingsView";
import ObjectSettingsView from "@/views/settings/ObjectSettingsView";
import MotionTunerView from "@/views/settings/MotionTunerView";
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
import AuthenticationView from "@/views/settings/AuthenticationView";
import UsersView from "@/views/settings/UsersView";
import RolesView from "@/views/settings/RolesView";
import NotificationView from "@/views/settings/NotificationsSettingsView";
import EnrichmentsSettingsView from "@/views/settings/EnrichmentsSettingsView";
import UiSettingsView from "@/views/settings/UiSettingsView";
@ -57,6 +58,7 @@ const allSettingsViews = [
"triggers",
"debug",
"users",
"roles",
"notifications",
"frigateplus",
] as const;
@ -288,7 +290,8 @@ export default function Settings() {
setUnsavedChanges={setUnsavedChanges}
/>
)}
{page == "users" && <AuthenticationView />}
{page == "users" && <UsersView />}
{page == "roles" && <RolesView />}
{page == "notifications" && (
<NotificationView setUnsavedChanges={setUnsavedChanges} />
)}

View File

@ -342,6 +342,12 @@ export interface FrigateConfig {
enabled: boolean;
};
auth: {
roles: {
[roleName: string]: string[];
};
};
birdseye: BirdseyeConfig;
cameras: {

View File

@ -65,6 +65,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
type RecordingViewProps = {
startCamera: string;
@ -97,11 +98,17 @@ export function RecordingView({
const timezone = useTimezone(config);
const allowedCameras = useAllowedCameras();
const effectiveCameras = useMemo(
() => allCameras.filter((camera) => allowedCameras.includes(camera)),
[allCameras, allowedCameras],
);
const { data: recordingsSummary } = useSWR<RecordingsSummary>([
"recordings/summary",
{
timezone: timezone,
cameras: allCameras.join(",") ?? null,
cameras: effectiveCameras.join(",") ?? null,
},
]);
@ -276,14 +283,16 @@ export function RecordingView({
const onSelectCamera = useCallback(
(newCam: string) => {
if (allowedCameras.includes(newCam)) {
setMainCamera(newCam);
setFullResolution({
width: 0,
height: 0,
});
setPlaybackStart(currentTime);
}
},
[currentTime],
[currentTime, allowedCameras],
);
// fullscreen
@ -488,12 +497,9 @@ export function RecordingView({
</div>
<div className="flex items-center justify-end gap-2">
<MobileCameraDrawer
allCameras={allCameras}
allCameras={effectiveCameras}
selected={mainCamera}
onSelectCamera={(cam) => {
setPlaybackStart(currentTime);
setMainCamera(cam);
}}
onSelectCamera={onSelectCamera}
/>
{isDesktop && (
<ExportDialog
@ -674,7 +680,7 @@ export function RecordingView({
containerRef={mainLayoutRef}
/>
</div>
{isDesktop && allCameras.length > 1 && (
{isDesktop && effectiveCameras.length > 1 && (
<div
ref={previewRowRef}
className={cn(
@ -686,7 +692,7 @@ export function RecordingView({
)}
>
<div className="w-2" />
{allCameras.map((cam) => {
{effectiveCameras.map((cam) => {
if (cam == mainCamera || cam == "birdseye") {
return;
}

View File

@ -33,6 +33,7 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
import SearchActionGroup from "@/components/filter/SearchActionGroup";
import { Trans, useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
type SearchViewProps = {
search: string;
@ -96,6 +97,7 @@ export default function SearchView({
);
// suggestions values
const allowedCameras = useAllowedCameras();
const allLabels = useMemo<string[]>(() => {
if (!config) {
@ -103,7 +105,9 @@ export default function SearchView({
}
const labels = new Set<string>();
const cameras = searchFilter?.cameras || Object.keys(config.cameras);
const cameras = (searchFilter?.cameras || allowedCameras).filter((camera) =>
allowedCameras.includes(camera),
);
cameras.forEach((camera) => {
if (camera == "birdseye") {
@ -128,7 +132,7 @@ export default function SearchView({
});
return [...labels].sort();
}, [config, searchFilter]);
}, [config, searchFilter, allowedCameras]);
const { data: allSubLabels } = useSWR("sub_labels");
const { data: allRecognizedLicensePlates } = useSWR(
@ -141,7 +145,9 @@ export default function SearchView({
}
const zones = new Set<string>();
const cameras = searchFilter?.cameras || Object.keys(config.cameras);
const cameras = (searchFilter?.cameras || allowedCameras).filter((camera) =>
allowedCameras.includes(camera),
);
cameras.forEach((camera) => {
if (camera == "birdseye") {
@ -160,11 +166,11 @@ export default function SearchView({
});
return [...zones].sort();
}, [config, searchFilter]);
}, [config, searchFilter, allowedCameras]);
const suggestionsValues = useMemo(
() => ({
cameras: Object.keys(config?.cameras || {}),
cameras: allowedCameras,
labels: Object.values(allLabels || {}),
zones: Object.values(allZones || {}),
sub_labels: allSubLabels,
@ -192,6 +198,7 @@ export default function SearchView({
allSubLabels,
allRecognizedLicensePlates,
searchFilter,
allowedCameras,
],
);

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { FrigateConfig } from "@/types/frigateConfig";
import { Toaster } from "@/components/ui/sonner";
@ -14,7 +14,7 @@ import DeleteUserDialog from "@/components/overlay/DeleteUserDialog";
import { HiTrash } from "react-icons/hi";
import { FaUserEdit } from "react-icons/fa";
import { LuPlus, LuShield, LuUserCog } from "react-icons/lu";
import { LuPencil, LuPlus, LuShield, LuUserCog } from "react-icons/lu";
import {
Table,
TableBody,
@ -31,22 +31,39 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import RoleChangeDialog from "@/components/overlay/RoleChangeDialog";
import CreateRoleDialog from "@/components/overlay/CreateRoleDialog";
import EditRoleCamerasDialog from "@/components/overlay/EditRoleCamerasDialog";
import { useTranslation } from "react-i18next";
import DeleteRoleDialog from "@/components/overlay/DeleteRoleDialog";
import { Separator } from "@/components/ui/separator";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
export default function AuthenticationView() {
type AuthenticationViewProps = {
section?: "users" | "roles";
};
export default function AuthenticationView({
section,
}: AuthenticationViewProps) {
const { t } = useTranslation("views/settings");
const { data: config } = useSWR<FrigateConfig>("config");
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const { data: users, mutate: mutateUsers } = useSWR<User[]>("users");
const [showSetPassword, setShowSetPassword] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [showRoleChange, setShowRoleChange] = useState(false);
const [showCreateRole, setShowCreateRole] = useState(false);
const [showEditRole, setShowEditRole] = useState(false);
const [showDeleteRole, setShowDeleteRole] = useState(false);
const [selectedUser, setSelectedUser] = useState<string>();
const [selectedUserRole, setSelectedUserRole] = useState<
"admin" | "viewer"
>();
const [selectedUserRole, setSelectedUserRole] = useState<string>();
const [selectedRole, setSelectedRole] = useState<string>();
const [currentRoleCameras, setCurrentRoleCameras] = useState<string[]>([]);
const [selectedRoleForDelete, setSelectedRoleForDelete] = useState<string>();
useEffect(() => {
document.title = t("documentTitle.authentication");
@ -82,11 +99,7 @@ export default function AuthenticationView() {
[t],
);
const onCreate = (
user: string,
password: string,
role: "admin" | "viewer",
) => {
const onCreate = (user: string, password: string, role: string) => {
axios
.post("users", { username: user, password, role })
.then((response) => {
@ -148,8 +161,8 @@ export default function AuthenticationView() {
});
};
const onChangeRole = (user: string, newRole: "admin" | "viewer") => {
if (user === "admin") return; // Prevent role change for 'admin'
const onChangeRole = (user: string, newRole: string) => {
if (user === "admin") return;
axios
.put(`users/${user}/role`, { role: newRole })
@ -184,6 +197,203 @@ export default function AuthenticationView() {
});
};
type ConfigSetBody = {
requires_restart: number;
config_data: {
auth: {
roles: {
[key: string]: string[] | string;
};
};
};
update_topic?: string;
};
const onCreateRole = useCallback(
async (role: string, cameras: string[]) => {
const configBody: ConfigSetBody = {
requires_restart: 0,
config_data: {
auth: {
roles: {
[role]: cameras,
},
},
},
update_topic: "config/auth",
};
return axios
.put("config/set", configBody)
.then((response) => {
if (response.status === 200) {
setShowCreateRole(false);
updateConfig();
toast.success(t("roles.toast.success.createRole", { role }), {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("roles.toast.error.createRoleFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
throw error;
});
},
[t, updateConfig],
);
const onEditRoleCameras = useCallback(
async (cameras: string[]) => {
if (!selectedRole) return;
const configBody: ConfigSetBody = {
requires_restart: 0,
config_data: {
auth: {
roles: {
[selectedRole]: cameras,
},
},
},
update_topic: "config/auth",
};
return axios
.put("config/set", configBody)
.then((response) => {
if (response.status === 200) {
setShowEditRole(false);
setSelectedRole(undefined);
setCurrentRoleCameras([]);
updateConfig();
toast.success(
t("roles.toast.success.updateCameras", { role: selectedRole }),
{
position: "top-center",
},
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("roles.toast.error.updateCamerasFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
throw error;
});
},
[t, selectedRole, updateConfig],
);
const onDeleteRole = useCallback(
async (role: string) => {
// Update users assigned to this role to 'viewer'
const usersToUpdate = users?.filter((user) => user.role === role) || [];
if (usersToUpdate.length > 0) {
Promise.all(
usersToUpdate.map((user) =>
axios.put(`users/${user.username}/role`, { role: "viewer" }),
),
)
.then(() => {
mutateUsers(
(users) =>
users?.map((u) =>
u.role === role ? { ...u, role: "viewer" } : u,
),
false,
);
toast.success(
t("roles.toast.success.userRolesUpdated", {
count: usersToUpdate.length,
}),
{ position: "top-center" },
);
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("roles.toast.error.userUpdateFailed", { errorMessage }),
{ position: "top-center" },
);
});
}
// Now delete the role from config
const configBody: ConfigSetBody = {
requires_restart: 0,
config_data: {
auth: {
roles: {
[role]: "",
},
},
},
update_topic: "config/auth",
};
return axios
.put("config/set", configBody)
.then((response) => {
if (response.status === 200) {
setShowDeleteRole(false);
setSelectedRoleForDelete("");
updateConfig();
toast.success(t("roles.toast.success.deleteRole", { role }), {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("roles.toast.error.deleteRoleFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
throw error;
});
},
[t, updateConfig, users, mutateUsers],
);
const roles = config?.auth?.roles
? Object.entries(config.auth.roles)
.filter(([name]) => name !== "admin")
.map(([name, data]) => ({
name,
cameras: Array.isArray(data) ? data : [],
}))
: [];
const availableRoles = useMemo(() => {
return config ? [...Object.keys(config.auth?.roles || {})] : [];
}, [config]);
if (!config || !users) {
return (
<div className="flex h-full w-full items-center justify-center">
@ -192,10 +402,9 @@ export default function AuthenticationView() {
);
}
return (
<div className="flex size-full flex-col md:flex-row">
<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">
// Users section
const UsersSection = (
<>
<div className="mb-5 flex flex-row items-center justify-between gap-2">
<div className="flex flex-col items-start">
<Heading as="h3" className="my-2">
@ -269,7 +478,8 @@ export default function AuthenticationView() {
<TableCell className="text-right">
<TooltipProvider>
<div className="flex items-center justify-end gap-2">
{user.username !== "admin" && (
{user.username !== "admin" &&
user.username !== "viewer" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -279,8 +489,7 @@ export default function AuthenticationView() {
onClick={() => {
setSelectedUser(user.username);
setSelectedUserRole(
(user.role as "admin" | "viewer") ||
"viewer",
user.role || "viewer",
);
setShowRoleChange(true);
}}
@ -353,8 +562,6 @@ export default function AuthenticationView() {
</div>
</div>
</div>
</div>
<SetPasswordDialog
show={showSetPassword}
onCancel={() => setShowSetPassword(false)}
@ -376,10 +583,218 @@ export default function AuthenticationView() {
show={showRoleChange}
username={selectedUser}
currentRole={selectedUserRole}
onSave={(role) => onChangeRole(selectedUser, role)}
availableRoles={availableRoles}
onSave={(role) => onChangeRole(selectedUser!, role)}
onCancel={() => setShowRoleChange(false)}
/>
)}
</>
);
// Roles section
const RolesSection = (
<>
<div className="mb-5 flex flex-row items-center justify-between gap-2">
<div className="flex flex-col items-start">
<Heading as="h3" className="my-2">
{t("roles.management.title")}
</Heading>
<p className="text-sm text-muted-foreground">
{t("roles.management.desc")}
</p>
</div>
<Button
className="flex items-center gap-2 self-start sm:self-auto"
aria-label={t("roles.addRole")}
variant="default"
onClick={() => setShowCreateRole(true)}
>
<LuPlus className="size-4" />
{t("roles.addRole")}
</Button>
</div>
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt">
<div className="h-full overflow-auto">
<Table>
<TableHeader className="sticky top-0 bg-muted/50">
<TableRow>
<TableHead className="w-[250px]">
{t("roles.table.role")}
</TableHead>
<TableHead>{t("roles.table.cameras")}</TableHead>
<TableHead className="text-right">
{t("roles.table.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roles.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center">
{t("roles.table.noRoles")}
</TableCell>
</TableRow>
) : (
roles.map((roleData) => (
<TableRow key={roleData.name} className="group">
<TableCell className="font-medium">
{roleData.name}
</TableCell>
<TableCell>
{roleData.cameras.length === 0 ? (
<Badge
variant="default"
className="bg-primary/20 text-xs text-primary hover:bg-primary/30"
>
{t("menu.live.allCameras", { ns: "common" })}
</Badge>
) : roleData.cameras.length > 5 ? (
<Badge variant="outline" className="text-xs">
{roleData.cameras.length} cameras
</Badge>
) : (
<div className="flex flex-wrap gap-1">
{roleData.cameras.map((camera) => (
<Badge
key={camera}
variant="outline"
className="text-xs"
>
<CameraNameLabel
camera={camera}
className="text-xs smart-capitalize"
/>
</Badge>
))}
</div>
)}
</TableCell>
<TableCell className="text-right">
<TooltipProvider>
<div className="flex items-center justify-end gap-2">
{roleData.name !== "admin" &&
roleData.name !== "viewer" && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 px-2"
onClick={() => {
setSelectedRole(roleData.name);
setCurrentRoleCameras(
roleData.cameras,
);
setShowEditRole(true);
}}
disabled={roleData.name === "admin"}
>
<LuPencil className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
{t("roles.table.editCameras")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("roles.table.editCameras")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="destructive"
className="h-8 px-2"
onClick={() => {
setSelectedRoleForDelete(
roleData.name,
);
setShowDeleteRole(true);
}}
disabled={roleData.name === "admin"}
>
<HiTrash className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
{t("button.delete", {
ns: "common",
})}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("roles.table.deleteRole")}</p>
</TooltipContent>
</Tooltip>
</>
)}
</div>
</TooltipProvider>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
<CreateRoleDialog
show={showCreateRole}
config={config}
onCreate={onCreateRole}
onCancel={() => setShowCreateRole(false)}
/>
{selectedRole && (
<EditRoleCamerasDialog
show={showEditRole}
config={config}
role={selectedRole}
currentCameras={currentRoleCameras}
onSave={onEditRoleCameras}
onCancel={() => {
setShowEditRole(false);
setSelectedRole(undefined);
setCurrentRoleCameras([]);
}}
/>
)}
<DeleteRoleDialog
show={showDeleteRole}
role={selectedRoleForDelete || ""}
onCancel={() => {
setShowDeleteRole(false);
setSelectedRoleForDelete("");
}}
onDelete={async () => {
if (selectedRoleForDelete) {
try {
await onDeleteRole(selectedRoleForDelete);
} catch (error) {
// Error handling is already done in onDeleteRole
}
}
}}
/>
</>
);
return (
<div className="flex size-full flex-col">
<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">
{section === "users" && UsersSection}
{section === "roles" && RolesSection}
{!section && (
<>
{UsersSection}
<Separator className="my-6 flex bg-secondary" />
{RolesSection}
</>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,5 @@
import AuthenticationView from "./AuthenticationView";
export default function RolesView() {
return <AuthenticationView section="roles" />;
}

View File

@ -0,0 +1,5 @@
import AuthenticationView from "./AuthenticationView";
export default function UsersView() {
return <AuthenticationView section="users" />;
}